Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/e2e-tests/tests/opencode2/adapters-s2-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,19 @@ test("I14 sdk_renames: v2 supplies all four host seams, v1 defaults retain funct
join(root, "packages/plugin/src/v2", file),
"utf8",
);
expect(source).not.toMatch(/\.\s*(abort|delete|promptAsync)\s*\(/);
// Host session lifecycle calls are forbidden on the v2 lane. Anchoring on
// `session.` keeps Map/Set `.delete(` (e.g. measuredUsageBySession.delete)
// from tripping this guard.
expect(source).not.toMatch(/session\.(abort|delete|promptAsync)\s*\(/);
expect(source).not.toMatch(/import\s+(?!type\b).*from\s+["']@opencode\//);
expect(source).not.toMatch(/live-session-state/);
if (file === "hooks/context.ts") {
// The RPC-server fix builds a FRESH LiveSessionState (createLiveSessionState
// is a factory) for the shared RPC handlers while keeping the lane's own
// draft-authoritative maps; importing the v1 state module is deliberate here.
expect(source).toMatch(/createLiveSessionState\(\)/);
} else {
expect(source).not.toMatch(/live-session-state/);
}
}
});

Expand Down
24 changes: 24 additions & 0 deletions packages/e2e-tests/tests/opencode2/store-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,30 @@ test("session_message_reader seq pages idle boundaries and checkpoint window", (
expect(() => new V2StoreReader(join(root, "missing.db"))).toThrow();
});

test("latestAssistant selects the newest assistant row by seq and ignores other types", () => {
const { root } = isolation();
const path = join(root, "latest-assistant.db");
const writer = new Database(path);
writer.exec(
"CREATE TABLE session_message(id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT)",
);
const insert = writer.prepare("INSERT INTO session_message VALUES (?, ?, ?, ?, ?)");
// Insert order deliberately shuffled so ORDER BY rowid cannot stand in for seq.
insert.run("m4", "ses-A", "assistant", 4, JSON.stringify({ model: { providerID: "p", id: "new" } }));
insert.run("m2", "ses-A", "user", 2, JSON.stringify({}));
insert.run("m1", "ses-A", "assistant", 1, JSON.stringify({ model: { providerID: "p", id: "old" } }));
insert.run("m3", "ses-B", "assistant", 3, JSON.stringify({ model: { providerID: "p", id: "other" } }));
const reader = new V2StoreReader(path);
try {
expect(reader.latestAssistant("ses-A")?.id).toBe("m4");
expect(reader.latestAssistant("ses-B")?.id).toBe("m3");
expect(reader.latestAssistant("ses-missing")).toBeUndefined();
} finally {
reader.close();
writer.close();
}
});

test("I11 v1/v2 readers feed the same transform core with pinned host differences", () => {
const { root } = isolation();
const sessionID = "ses-golden";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { formatDreamTaskBacklogs } from "./task-registry";
import type { ManualRunResult } from "./task-scheduler";

/**
* Render a manual `/ctx-dream` run for the user. Shared by the v1 command
* output and the v2 RPC notification so the two surfaces stay identical.
*/
export function summarizeManualDream(summary: ManualRunResult): string {
const lines: string[] = ["## /ctx-dream", ""];
if (summary.ran.length > 0) lines.push(`Ran: ${summary.ran.join(", ")}`);
if ((summary.details?.length ?? 0) > 0) {
lines.push("Details:", ...(summary.details ?? []).map((detail) => `- ${detail}`));
}
if (summary.failed.length > 0) lines.push(`Failed: ${summary.failed.join(", ")}`);
if ((summary.failureDetails?.length ?? 0) > 0) {
lines.push(
"Failure details:",
...(summary.failureDetails ?? []).map((detail) => `- ${detail}`),
);
}
if (summary.skippedNoWork.length > 0)
lines.push(`Skipped (no work): ${summary.skippedNoWork.join(", ")}`);
if (summary.deferredBusy.length > 0)
lines.push(
// "Busy" means the task's DOMAIN lease is held — usually a sibling
// task (e.g. a scheduled verify blocking a manual curate), not
// this task itself. Say so, or the message reads as a lie.
`Busy: ${summary.deferredBusy.join(", ")} — another dream task holds this domain's lease; retry in a minute`,
);
if (Object.keys(summary.backlogBefore ?? {}).length > 0) {
lines.push(
"",
"Backlog at run start:",
formatDreamTaskBacklogs(summary.backlogBefore ?? {}),
);
}
if (Object.keys(summary.backlogAfter ?? {}).length > 0) {
lines.push("", "Backlog at run end:", formatDreamTaskBacklogs(summary.backlogAfter ?? {}));
}
if (
summary.ran.length === 0 &&
summary.failed.length === 0 &&
summary.skippedNoWork.length === 0 &&
summary.deferredBusy.length === 0
) {
lines.push("No enabled dream tasks to run.");
}
return lines.join("\n");
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,35 @@ describe("task-scheduler — planDueTasks", () => {
expect(getTaskScheduleState(db, PROJECT, "curate")).not.toBeNull();
});

it("prunes against the canonical set, not the caller's filtered list", () => {
db = freshDb();
// A canonical task the caller's execution list omits (e.g. a capability
// filter on a host without a tool loop) must keep its durable schedule row
// and cursors — a capability filter selects what to run, it must never
// define what is canonical.
writeTaskScheduleState(db, {
projectPath: PROJECT,
task: "map-memories",
lastRunAt: 1234,
nextDueAt: Date.now() + 60_000,
schedule: "0 3 * * *",
lastStatus: "completed",
lastError: null,
retryCount: 0,
lastCheckedCommit: "abc",
retrospectiveWatermarkMs: 99,
});
planDueTasks(
db,
PROJECT,
[cfg("verify", "0 3 * * *"), cfg("curate", "0 4 * * 0")],
Date.now(),
);
const preserved = getTaskScheduleState(db, PROJECT, "map-memories");
expect(preserved?.lastRunAt).toBe(1234);
expect(preserved?.retrospectiveWatermarkMs).toBe(99);
});

it("deleteTaskScheduleRowsForProject removes ALL rows for an orphaned project only", () => {
db = freshDb();
const orphan = "dir:deadworktree";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./storage-task-schedule";
import { evaluateTaskGate, getDreamTaskBacklogs } from "./task-gates";
import {
CANONICAL_DREAM_TASKS,
compareTaskOrder,
type DreamTaskBacklog,
type DreamTaskBacklogMap,
Expand Down Expand Up @@ -172,13 +173,12 @@ export function planDueTasks(
): DueTask[] {
// GC retired task rows: improve, consolidate, and archive-stale were replaced
// by verify/curate, while render-mural was removed when the scheduler switched
// to its deterministic task set. Since `tasks` contains the full canonical set,
// any stored row outside it is obsolete. Cheap and idempotent.
const pruned = pruneNonCanonicalTaskRows(
db,
projectIdentity,
tasks.map((t) => t.task),
);
// to its deterministic task set. Prune against the canonical set — NOT the
// passed task list: callers may filter that list for execution (e.g. a host
// without a tool loop), and a capability filter must never delete canonical
// schedule rows (last_run_at / next_due_at / watermarks) for the whole project.
// Cheap and idempotent.
const pruned = pruneNonCanonicalTaskRows(db, projectIdentity, CANONICAL_DREAM_TASKS);
if (pruned > 0) {
log(`[dreamer] pruned ${pruned} retired task row(s) for ${projectIdentity}`);
}
Expand Down
36 changes: 1 addition & 35 deletions packages/plugin/src/hooks/magic-context/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { COMPACTION_ENABLED_PATH } from "../../config/agent-disable";
import type { DreamerConfig, MagicContextConfig } from "../../config/schema/magic-context";
import type { ResolvedTransformMode } from "../../config/transform-mode";
import type { MagicContextBuiltinCommandName } from "../../features/builtin-commands/commands";
import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary";
import { getDreamTaskBacklogs } from "../../features/magic-context/dreamer/task-gates";
import {
CANONICAL_DREAM_TASKS,
Expand Down Expand Up @@ -375,41 +376,6 @@ function readDreamTaskBacklogsSafely(
}
}

function summarizeManualDream(s: ManualDreamSummary): string {
const lines: string[] = ["## /ctx-dream", ""];
if (s.ran.length > 0) lines.push(`Ran: ${s.ran.join(", ")}`);
if ((s.details?.length ?? 0) > 0) {
lines.push("Details:", ...(s.details ?? []).map((detail) => `- ${detail}`));
}
if (s.failed.length > 0) lines.push(`Failed: ${s.failed.join(", ")}`);
if ((s.failureDetails?.length ?? 0) > 0) {
lines.push("Failure details:", ...(s.failureDetails ?? []).map((detail) => `- ${detail}`));
}
if (s.skippedNoWork.length > 0) lines.push(`Skipped (no work): ${s.skippedNoWork.join(", ")}`);
if (s.deferredBusy.length > 0)
lines.push(
// "Busy" means the task's DOMAIN lease is held — usually a sibling
// task (e.g. a scheduled verify blocking a manual curate), not
// this task itself. Say so, or the message reads as a lie.
`Busy: ${s.deferredBusy.join(", ")} — another dream task holds this domain's lease; retry in a minute`,
);
if (Object.keys(s.backlogBefore ?? {}).length > 0) {
lines.push("", "Backlog at run start:", formatDreamTaskBacklogs(s.backlogBefore ?? {}));
}
if (Object.keys(s.backlogAfter ?? {}).length > 0) {
lines.push("", "Backlog at run end:", formatDreamTaskBacklogs(s.backlogAfter ?? {}));
}
if (
s.ran.length === 0 &&
s.failed.length === 0 &&
s.skippedNoWork.length === 0 &&
s.deferredBusy.length === 0
) {
lines.push("No enabled dream tasks to run.");
}
return lines.join("\n");
}

async function executeDreaming(
deps: {
db: Database;
Expand Down
137 changes: 137 additions & 0 deletions packages/plugin/src/hooks/magic-context/read-session-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,62 @@ function createOpenCodeDb(rows: MessageRow[]): void {
}
}

function insertV2SessionMessages(
rows: Array<{
id: string;
sessionId: string;
type: "user" | "assistant" | "compaction";
seq: number;
model?: { providerID?: string; id?: string };
agent?: string;
}>,
options: { v2Marker?: boolean } = {},
): void {
const dbPath = join(process.env.XDG_DATA_HOME!, "opencode", "opencode.db");
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
try {
db.exec(`
-- A migrated store keeps the v1 tables beside the v2 schema; the
-- read-only session DB guard classifies message+part as v1.
CREATE TABLE IF NOT EXISTS part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
type TEXT NOT NULL,
seq INTEGER NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_v2 (
id TEXT PRIMARY KEY
);
`);
if (options.v2Marker === false) db.exec("DROP TABLE IF EXISTS session_v2");
const insert = db.prepare(
`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const now = Date.now();
for (const row of rows) {
const data: Record<string, unknown> = {};
if (row.model !== undefined) data.model = row.model;
if (row.agent !== undefined) data.agent = row.agent;
insert.run(row.id, row.sessionId, row.type, row.seq, now, now, JSON.stringify(data));
}
} finally {
closeQuietly(db);
}
}

describe("latestPersistedMessageForRecovery", () => {
it("reports when the latest assistant child has completed", () => {
useTempDataHome("read-session-db-recovery-completed-");
Expand Down Expand Up @@ -626,6 +682,87 @@ describe("latestPersistedMessageForRecovery", () => {
});

describe("findLastAssistantModelFromOpenCodeDb", () => {
it("prefers the v2 session_message table on a migrated store", () => {
useTempDataHome("read-session-db-v2-preference-");
createOpenCodeDb([
{
id: "msg_stale",
sessionId: "ses_A",
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet-4.5",
timeCreated: 1000,
},
]);
insertV2SessionMessages([
{ id: "sms_user", sessionId: "ses_A", type: "user", seq: 1 },
{
id: "sms_asst",
sessionId: "ses_A",
type: "assistant",
seq: 2,
model: { providerID: "commandcode", id: "deepseek/deepseek-v4.1-flash" },
agent: "build",
},
]);
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
providerID: "commandcode",
modelID: "deepseek/deepseek-v4.1-flash",
agent: "build",
});
});

it("falls back to the v1 table when the v2 table has no assistant rows", () => {
useTempDataHome("read-session-db-v2-fallback-");
createOpenCodeDb([
{
id: "msg_legacy",
sessionId: "ses_A",
role: "assistant",
providerID: "anthropic",
modelID: "claude-opus-4-7",
timeCreated: 1000,
},
]);
insertV2SessionMessages([{ id: "sms_user", sessionId: "ses_A", type: "user", seq: 1 }]);
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-7",
});
});

it("does not consult the v2 table on a 1.18 store that also ships session_message", () => {
useTempDataHome("read-session-db-v1-session-message-");
createOpenCodeDb([
{
id: "msg_live",
sessionId: "ses_A",
role: "assistant",
providerID: "anthropic",
modelID: "claude-opus-4-7",
timeCreated: 2000,
},
]);
// 1.18 stores ship session_message but no session_v2 marker; a stale
// v2-shaped row must not win over the live v1 row.
insertV2SessionMessages(
[
{
id: "sms_stale",
sessionId: "ses_A",
type: "assistant",
seq: 9,
model: { providerID: "stale", id: "stale-model" },
},
],
{ v2Marker: false },
);
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-7",
});
});

it("returns null for a session with no assistant messages", () => {
useTempDataHome("read-session-db-no-assistant-");
createOpenCodeDb([
Expand Down
Loading
Loading