feat: auto-sync bound codex threads into conversations
This commit is contained in:
@@ -485,3 +485,70 @@ test("device import routes reject unrelated logged-in members", async () => {
|
||||
});
|
||||
assert.equal(getResponse.status, 403);
|
||||
});
|
||||
|
||||
test("existing bound production devices auto-sync suggested candidates into conversations on heartbeat", async () => {
|
||||
await setup();
|
||||
|
||||
const heartbeatResponse = await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: "mac-studio",
|
||||
token: "boss-mac-studio-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 68,
|
||||
quota7d: 81,
|
||||
projects: ["Boss 移动控制台", "硬件审计协作"],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "yuandi",
|
||||
folderRef: "/Users/kris/code/yuandi",
|
||||
threadId: "session-yuandi-1",
|
||||
threadDisplayName: "Epicurus",
|
||||
codexFolderRef: "/Users/kris/code/yuandi",
|
||||
codexThreadRef: "session-yuandi-1",
|
||||
lastActiveAt: "2026-03-30T12:42:56+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
{
|
||||
folderName: "wenshenapp",
|
||||
folderRef: "/Users/kris/code/wenshenapp",
|
||||
threadId: "session-wenshenapp-1",
|
||||
threadDisplayName: "wenshenapp · ea5f",
|
||||
codexFolderRef: "/Users/kris/code/wenshenapp",
|
||||
codexThreadRef: "session-wenshenapp-1",
|
||||
lastActiveAt: "2026-03-30T12:34:51+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(heartbeatResponse.status, 200);
|
||||
|
||||
const payload = (await heartbeatResponse.json()) as {
|
||||
importDraft?: { status: string; selectedCandidateIds: string[] } | null;
|
||||
};
|
||||
assert.equal(payload.importDraft?.status, "applied");
|
||||
assert.equal(payload.importDraft?.selectedCandidateIds.length, 2);
|
||||
|
||||
const nextState = await readState();
|
||||
const yuandiProject = nextState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "session-yuandi-1",
|
||||
);
|
||||
const wenshenProject = nextState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "session-wenshenapp-1",
|
||||
);
|
||||
assert.ok(yuandiProject, "expected a discovered yuandi session to become a real chat window");
|
||||
assert.ok(wenshenProject, "expected a discovered wenshenapp session to become a real chat window");
|
||||
assert.equal(yuandiProject?.threadMeta.folderName, "yuandi");
|
||||
assert.equal(wenshenProject?.threadMeta.folderName, "wenshenapp");
|
||||
|
||||
const device = nextState.devices.find((item) => item.id === "mac-studio");
|
||||
assert.deepEqual(device?.projects, ["yuandi", "wenshenapp"]);
|
||||
});
|
||||
|
||||
196
tests/local-agent-codex-discovery.test.mjs
Normal file
196
tests/local-agent-codex-discovery.test.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let discoverCodexProjectCandidates;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-discovery-"));
|
||||
({ discoverCodexProjectCandidates } = await import("../local-agent/codex-session-discovery.mjs"));
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session names over raw rollout fallback", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex");
|
||||
const now = new Date("2026-03-30T12:45:00+08:00");
|
||||
await mkdir(codexRoot, { recursive: true });
|
||||
const stateDbPath = path.join(codexRoot, "state_5.sqlite");
|
||||
const logsDbPath = path.join(codexRoot, "logs_1.sqlite");
|
||||
const sessionIndexPath = path.join(codexRoot, "session_index.jsonl");
|
||||
const globalStatePath = path.join(codexRoot, ".codex-global-state.json");
|
||||
|
||||
const stateDb = new DatabaseSync(stateDbPath);
|
||||
stateDb.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
const insertThread = stateDb.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1, ?, '0.118.0', '', ?, ?, 'enabled', 'gpt-5.4', 'medium')
|
||||
`);
|
||||
insertThread.run(
|
||||
"019d3bossmain",
|
||||
path.join(codexRoot, "sessions/2026/03/30/rollout-boss-main.jsonl"),
|
||||
1774845600,
|
||||
1774845618,
|
||||
"desktop",
|
||||
"openai",
|
||||
"/Users/kris/code/boss",
|
||||
"Boss 主线程",
|
||||
"workspace-write",
|
||||
"never",
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
insertThread.run(
|
||||
"019d3yuandiexplorer",
|
||||
path.join(codexRoot, "sessions/2026/03/30/rollout-yuandi-explorer.jsonl"),
|
||||
1774845760,
|
||||
1774845776,
|
||||
"desktop",
|
||||
"openai",
|
||||
"/Users/kris/.codex/worktrees/tmp123/yuandi",
|
||||
"Yuandi 子线程",
|
||||
"workspace-write",
|
||||
"never",
|
||||
0,
|
||||
"Epicurus",
|
||||
"explorer",
|
||||
);
|
||||
insertThread.run(
|
||||
"019d3oldsession",
|
||||
path.join(codexRoot, "sessions/2026/03/27/rollout-too-old.jsonl"),
|
||||
1774584000,
|
||||
1774584000,
|
||||
"desktop",
|
||||
"openai",
|
||||
"/Users/kris/code/old-project",
|
||||
"Old Session",
|
||||
"workspace-write",
|
||||
"never",
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
stateDb.close();
|
||||
|
||||
const logsDb = new DatabaseSync(logsDbPath);
|
||||
logsDb.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
feedback_log_body TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
const insertLog = logsDb.prepare(`
|
||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes)
|
||||
VALUES (?, 0, 'info', 'codex', ?, 0)
|
||||
`);
|
||||
insertLog.run(1774845618, "019d3bossmain");
|
||||
insertLog.run(1774845776, "019d3yuandiexplorer");
|
||||
logsDb.close();
|
||||
|
||||
await writeFile(
|
||||
sessionIndexPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
id: "019d3bossmain",
|
||||
thread_name: "Boss 主线程",
|
||||
updated_at: "2026-03-30T04:40:18.000000Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "019d3yuandiexplorer",
|
||||
thread_name: "Epicurus",
|
||||
updated_at: "2026-03-30T04:42:56.000000Z",
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
globalStatePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"thread-workspace-root-hints": {
|
||||
"019d3yuandiexplorer": "/Users/kris/code/yuandi",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
sessionIndexPath,
|
||||
globalStatePath,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.deepEqual(discovered.projects, ["boss", "yuandi"]);
|
||||
assert.equal(discovered.projectCandidates.length, 2);
|
||||
|
||||
const bossSession = discovered.projectCandidates.find((item) => item.threadId === "019d3bossmain");
|
||||
const yuandiSession = discovered.projectCandidates.find((item) => item.threadId === "019d3yuandiexplorer");
|
||||
assert.ok(bossSession);
|
||||
assert.ok(yuandiSession);
|
||||
assert.equal(bossSession?.folderName, "boss");
|
||||
assert.equal(bossSession?.codexFolderRef, "/Users/kris/code/boss");
|
||||
assert.equal(bossSession?.codexThreadRef, "019d3bossmain");
|
||||
assert.equal(bossSession?.threadDisplayName, "Boss 主线程");
|
||||
assert.equal(yuandiSession?.folderName, "yuandi");
|
||||
assert.equal(yuandiSession?.threadDisplayName, "Epicurus");
|
||||
assert.equal(yuandiSession?.codexFolderRef, "/Users/kris/code/yuandi");
|
||||
});
|
||||
Reference in New Issue
Block a user