feat: detect external human turns in adopted Pi and OpenCode sessions (#113957)

* feat(plugins): support additional upstream session kinds

* feat(acpx): monitor upstream Pi sessions

* feat(opencode): monitor upstream OpenCode sessions

* fix(opencode): ignore hidden user text activity

* refactor(plugins): share upstream echo filtering

* fix(opencode): preserve marker tuple narrowing

* refactor(opencode): detect upstream turns via event_sequence cursor

Replace timestamp/grace-window completeness heuristics with OpenCode's own
per-session event_sequence.seq cursor, which advances inside the same
transaction as the message/part projections. Detects change instead of
inferring completion, so staged part writes can no longer drop a turn.

Handles cursor regression from OpenCode migrations that clear event_sequence,
suppresses compaction replay and summary re-publish, and restricts human-turn
classification to visible non-synthetic text.

* docs(plugins): record why the adoption coordinator requires complete

* fix(opencode): align cursor proof with strict types

* fix(acpx): keep Pi JSON parser private
This commit is contained in:
Peter Steinberger
2026-07-25 19:05:22 -07:00
committed by GitHub
parent 17ccfc4b98
commit 76ee87539e
16 changed files with 1671 additions and 81 deletions
+12 -2
View File
@@ -46,6 +46,10 @@ import {
readLocalOpenCodeTranscriptPage,
type OpenCodeSessionPage,
} from "./session-catalog.js";
import {
checkOpenCodeUpstreamActivity,
linkContinuedOpenCodeSession,
} from "./session-upstream-activity.js";
const MAX_HOSTS = 100;
const TRANSCRIPT_ITEM_TYPES = new Set([
@@ -60,7 +64,10 @@ const ACPX_BACKEND_ID = "acpx";
const OPENCODE_ACP_AGENT_ID = "opencode";
const OPENCODE_ADOPTED_SESSION_KEY_PREFIX = "plugin:opencode:catalog-adopt:";
const continueAdoption = createSessionCatalogAdoptionCoordinator();
const continueAdoption =
createSessionCatalogAdoptionCoordinator<
Awaited<ReturnType<typeof linkContinuedOpenCodeSession>>
>();
function isOptionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
@@ -456,7 +463,7 @@ async function continueOpenCodeSession(
api: OpenClawPluginApi,
hostId: string,
threadId: string,
): Promise<{ sessionKey: string }> {
): Promise<Awaited<ReturnType<typeof linkContinuedOpenCodeSession>>> {
if (hostId.startsWith("node:")) {
throw new OpenCodeCatalogParamsError("paired-node OpenCode session rows are view-only");
}
@@ -517,6 +524,8 @@ async function continueOpenCodeSession(
});
return { sessionKey: created.key };
},
complete: async (continued) =>
await linkContinuedOpenCodeSession(continued.sessionKey, threadId),
});
}
@@ -531,6 +540,7 @@ export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void {
read: async (request) => await readOpenCodeTranscript(api.runtime, request),
continueSession: async (request) =>
await continueOpenCodeSession(api, request.hostId, request.threadId),
checkUpstreamActivity: checkOpenCodeUpstreamActivity,
openTerminal: async (request) =>
await openOpenCodeCatalogTerminal({
runtime: api.runtime,
+18 -1
View File
@@ -197,7 +197,16 @@ async function installFakeOpenCode(
const args = process.argv.slice(2);
if (process.env.CATALOG_UNRELATED_ENV) process.exit(3);
if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && args.includes("json")) {
process.stdout.write(${JSON.stringify(JSON.stringify([session]))});
process.stdout.write(args[2].includes("event_sequence")
? ${JSON.stringify(
JSON.stringify([
{
id: "ses_test",
seq: 4,
},
]),
)}
: ${JSON.stringify(JSON.stringify([session]))});
} else if (args[0] === "--pure" && args[1] === "export" && args[2] === "ses_test") {
process.stdout.write(${JSON.stringify(JSON.stringify(exported))});
} else {
@@ -419,6 +428,14 @@ describe("OpenCode session catalog", () => {
expect(first).toEqual(concurrent);
expect(second).toEqual(first);
expect(first.upstream).toEqual({
kind: "opencode-cli",
ref: { threadId: "ses_test" },
marker: {
seq: 4,
lastHumanMessageId: "msg_user",
},
});
expect(createSessionEntry).toHaveBeenCalledTimes(1);
expect(createSessionEntry).toHaveBeenCalledWith(
expect.objectContaining({
+12 -4
View File
@@ -302,6 +302,16 @@ async function runOpenCode(args: string[]): Promise<string> {
return Buffer.concat(stdout).toString("utf8");
}
export async function queryOpenCodeDatabase(query: string): Promise<unknown> {
const output = await runOpenCode(["--pure", "db", query, "--format", "json"]);
return output.trim() ? (JSON.parse(output) as unknown) : [];
}
export async function exportOpenCodeSession(threadId: string): Promise<unknown> {
const output = await runOpenCode(["--pure", "export", threadId]);
return JSON.parse(output) as unknown;
}
function parseOpenCodeSession(value: unknown): SessionCatalogSession | undefined {
if (!isRecord(value)) {
return undefined;
@@ -343,8 +353,7 @@ export async function listLocalOpenCodeSessionPage(value?: unknown): Promise<Ope
"WHERE parent_id IS NULL AND time_archived IS NULL",
`ORDER BY time_updated DESC, id DESC LIMIT ${String(requestedCount)}`,
].join(" ");
const output = await runOpenCode(["--pure", "db", query, "--format", "json"]);
const parsed = output.trim() ? (JSON.parse(output) as unknown) : [];
const parsed = await queryOpenCodeDatabase(query);
if (!Array.isArray(parsed) || parsed.length > MAX_CLI_LIST_SESSIONS) {
throw new Error("OpenCode returned an invalid session list");
}
@@ -478,8 +487,7 @@ export async function readLocalOpenCodeTranscriptPage(
): Promise<SessionsCatalogReadResult> {
const params = parseReadParams(value);
const offset = decodeCursor(params.cursor);
const output = await runOpenCode(["--pure", "export", params.threadId]);
const items = openCodeTranscriptItems(JSON.parse(output) as unknown);
const items = openCodeTranscriptItems(await exportOpenCodeSession(params.threadId));
const page = transcriptPage(items, params.limit, offset);
return {
hostId: LOCAL_HOST_ID,
@@ -0,0 +1,653 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
checkOpenCodeUpstreamActivity,
linkContinuedOpenCodeSession,
} from "./session-upstream-activity.js";
type StatefulOpenCodeSession = {
id: string;
title: string;
directory: string;
seq?: number;
messages: Array<{
info: { id: string; role: string; time: { created: number } };
parts: Array<{
id: string;
type: string;
text?: string;
synthetic?: boolean;
ignored?: boolean;
metadata?: Record<string, unknown>;
mime?: string;
filename?: string;
source?: { text: { value: string; start: number; end: number } };
}>;
}>;
};
type Probe = Parameters<typeof checkOpenCodeUpstreamActivity>[0][number];
const temporaryDirectories: string[] = [];
const originalPath = process.env.PATH;
afterEach(async () => {
process.env.PATH = originalPath;
await Promise.all(
temporaryDirectories.splice(0).map(async (directory) => {
await fs.rm(directory, { recursive: true, force: true });
}),
);
});
async function installStatefulOpenCode(initialSessions: StatefulOpenCodeSession[]) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-opencode-activity-"));
temporaryDirectories.push(directory);
const executable = path.join(directory, "opencode");
const stateFile = path.join(directory, "state.json");
const logFile = path.join(directory, "calls.jsonl");
const activeExportsDirectory = path.join(directory, "active-exports");
const exportConcurrencyLog = path.join(directory, "export-concurrency.log");
await fs.mkdir(activeExportsDirectory);
const writeState = async (state: {
sessions: StatefulOpenCodeSession[];
failDb?: boolean;
failExports?: string[];
exportDelayMs?: number;
}) => await fs.writeFile(stateFile, JSON.stringify(state));
await writeState({ sessions: initialSessions });
await fs.writeFile(logFile, "");
await fs.writeFile(
executable,
"#!/usr/bin/env node\n" +
`const fs = require("node:fs");
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(logFile)}, JSON.stringify(args) + "\\n");
const state = JSON.parse(fs.readFileSync(${JSON.stringify(stateFile)}, "utf8"));
if (args[0] === "--pure" && args[1] === "db") {
if (state.failDb) process.exit(4);
const query = args[2];
const selected = state.sessions.filter((session) => query.includes("'" + session.id + "'"));
process.stdout.write(JSON.stringify(selected.map((session) => ({
id: session.id,
seq: session.seq ?? null,
}))));
} else if (args[0] === "--pure" && args[1] === "export") {
const session = state.sessions.find((candidate) => candidate.id === args[2]);
if (!session || state.failExports?.includes(args[2])) process.exit(5);
const activeMarker = ${JSON.stringify(activeExportsDirectory)} + "/" + process.pid;
fs.writeFileSync(activeMarker, "");
fs.appendFileSync(
${JSON.stringify(exportConcurrencyLog)},
String(fs.readdirSync(${JSON.stringify(activeExportsDirectory)}).length) + "\\n",
);
const finish = () => {
fs.rmSync(activeMarker, { force: true });
process.stdout.write(JSON.stringify({ info: session, messages: session.messages }));
};
if (state.exportDelayMs) setTimeout(finish, state.exportDelayMs);
else finish();
} else {
process.exit(2);
}
`,
);
await fs.chmod(executable, 0o755);
process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`;
return {
writeState,
clearLog: async () => await fs.writeFile(logFile, ""),
readCalls: async () =>
(await fs.readFile(logFile, "utf8"))
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]),
readMaxExportConcurrency: async () =>
Math.max(
0,
...(await fs.readFile(exportConcurrencyLog, "utf8").catch(() => ""))
.trim()
.split("\n")
.filter(Boolean)
.map(Number),
),
};
}
function openCodeMessage(id: string, role: string, text: string, created: number) {
return {
info: { id, role, time: { created } },
parts: [{ id: `part-${id}`, type: "text", text }],
};
}
function probe(marker: Probe["marker"], ownRecentUserTexts: string[] = []): Probe {
return {
sessionKey: "agent:main:ses-a",
agentId: "main",
threadId: "ses_a",
hostId: "gateway",
upstreamKind: "opencode-cli",
upstreamRef: { threadId: "ses_a" },
marker,
ownRecentUserTexts,
};
}
function markerFrom(
outcome: Awaited<ReturnType<typeof checkOpenCodeUpstreamActivity>>[number] | undefined,
) {
if (outcome?.kind !== "activity") {
throw new Error("expected activity marker");
}
return outcome.nextMarker;
}
describe("OpenCode session upstream activity", () => {
it.runIf(process.platform !== "win32")(
"uses event_sequence and exports only after the cursor advances",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 1,
messages: [openCodeMessage("msg_001", "assistant", "ready", 1_700_000_000_000)],
};
const fixture = await installStatefulOpenCode([session]);
const continued = await linkContinuedOpenCodeSession("agent:main:ses-a", "ses_a");
expect(continued.upstream?.marker).toEqual({ seq: 1, lastHumanMessageId: null });
await fixture.clearLog();
await expect(
checkOpenCodeUpstreamActivity([probe(continued.upstream!.marker)]),
).resolves.toEqual([]);
let calls = await fixture.readCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[2]).toBe(
"SELECT s.id AS id, es.seq AS seq FROM session AS s LEFT JOIN event_sequence AS es ON es.aggregate_id = s.id WHERE s.id IN ('ses_a')",
);
session.messages.push(
openCodeMessage("msg_002", "user", "external OpenCode turn", 1_700_000_001_000),
);
session.seq = 3;
await fixture.writeState({ sessions: [session] });
await fixture.clearLog();
await expect(
checkOpenCodeUpstreamActivity([probe(continued.upstream!.marker)]),
).resolves.toEqual([
expect.objectContaining({
kind: "activity",
humanTurns: 1,
dedupeId: "msg_002",
nextMarker: { seq: 3, lastHumanMessageId: "msg_002" },
}),
]);
calls = await fixture.readCalls();
expect(calls.filter((args) => args[1] === "db")).toHaveLength(1);
expect(calls.filter((args) => args[1] === "export")).toHaveLength(1);
},
);
it.runIf(process.platform !== "win32")(
"re-baselines a regressed cursor and keeps monitoring the next advance",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 0,
messages: [openCodeMessage("msg_001", "user", "old turn", 1_700_000_000_000)],
};
const fixture = await installStatefulOpenCode([session]);
const reset = await checkOpenCodeUpstreamActivity([
probe({ seq: 12, lastHumanMessageId: "msg_001" }),
]);
expect(reset).toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 0, lastHumanMessageId: "msg_001" },
},
]);
expect((await fixture.readCalls()).filter((args) => args[1] === "export")).toHaveLength(0);
session.messages.push(
openCodeMessage("msg_002", "user", "new after migration", 1_700_000_001_000),
);
session.seq = 2;
await fixture.writeState({ sessions: [session] });
await expect(checkOpenCodeUpstreamActivity([probe(markerFrom(reset[0]))])).resolves.toEqual([
expect.objectContaining({
humanTurns: 1,
nextMarker: { seq: 2, lastHumanMessageId: "msg_002" },
}),
]);
},
);
it.runIf(process.platform !== "win32")(
"waits across staged part projections and reports the human turn exactly once",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 1,
messages: [openCodeMessage("msg_001", "assistant", "ready", 1_700_000_000_000)],
};
const fixture = await installStatefulOpenCode([session]);
let currentMarker: Probe["marker"] = { seq: 1, lastHumanMessageId: null };
const user = {
info: { id: "msg_002", role: "user", time: { created: 1_700_000_001_000 } },
parts: [] as StatefulOpenCodeSession["messages"][number]["parts"],
};
session.messages.push(user);
session.seq = 2;
await fixture.writeState({ sessions: [session] });
let outcomes = await checkOpenCodeUpstreamActivity([probe(currentMarker)]);
expect(outcomes).toEqual([expect.objectContaining({ humanTurns: 0 })]);
currentMarker = markerFrom(outcomes[0]);
user.parts.push({ id: "part-file", type: "file" });
session.seq = 3;
await fixture.writeState({ sessions: [session] });
outcomes = await checkOpenCodeUpstreamActivity([probe(currentMarker)]);
expect(outcomes).toEqual([expect.objectContaining({ humanTurns: 0 })]);
currentMarker = markerFrom(outcomes[0]);
user.parts.push({ id: "part-text", type: "text", text: "arrived in stages" });
session.seq = 4;
await fixture.writeState({ sessions: [session] });
outcomes = await checkOpenCodeUpstreamActivity([probe(currentMarker)]);
expect(outcomes).toEqual([
expect.objectContaining({
humanTurns: 1,
nextMarker: { seq: 4, lastHumanMessageId: "msg_002" },
}),
]);
currentMarker = markerFrom(outcomes[0]);
user.parts.push({ id: "part-late", type: "text", text: "late detail" });
session.seq = 5;
await fixture.writeState({ sessions: [session] });
await expect(checkOpenCodeUpstreamActivity([probe(currentMarker)])).resolves.toEqual([
expect.objectContaining({
humanTurns: 0,
nextMarker: { seq: 5, lastHumanMessageId: "msg_002" },
}),
]);
},
);
it.runIf(process.platform !== "win32")(
"suppresses compaction replay text duplicated from an earlier user turn",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 9,
messages: [
{
info: { id: "msg_001", role: "user", time: { created: 1_700_000_000_000 } },
parts: [
{ id: "part-hidden", type: "text", text: "hidden", ignored: true },
{ id: "part-visible", type: "text", text: "please keep going" },
],
},
openCodeMessage("msg_002", "assistant", "working", 1_700_000_001_000),
{
info: { id: "msg_003", role: "user", time: { created: 1_700_000_002_000 } },
parts: [{ id: "part-compact", type: "compaction" }],
},
openCodeMessage("msg_004", "assistant", "summary", 1_700_000_003_000),
openCodeMessage("msg_005", "user", " please keep going ", 1_700_000_004_000),
],
};
await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 4, lastHumanMessageId: "msg_001" })]),
).resolves.toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 9, lastHumanMessageId: "msg_005" },
},
]);
},
);
it.runIf(process.platform !== "win32")(
"normalizes media placeholders when suppressing compaction replay",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 7,
messages: [
{
info: { id: "msg_001", role: "user", time: { created: 1_700_000_000_000 } },
parts: [
{
id: "part-image",
type: "file",
mime: "image/png",
filename: "screen.png",
},
],
},
openCodeMessage("msg_002", "assistant", "working", 1_700_000_001_000),
{
info: { id: "msg_003", role: "user", time: { created: 1_700_000_002_000 } },
parts: [{ id: "part-compact", type: "compaction" }],
},
openCodeMessage("msg_004", "assistant", "summary", 1_700_000_003_000),
openCodeMessage("msg_005", "user", "[Attached image/png: screen.png]", 1_700_000_004_000),
],
};
await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 2, lastHumanMessageId: null })]),
).resolves.toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 7, lastHumanMessageId: "msg_005" },
},
]);
},
);
it.runIf(process.platform !== "win32")(
"bounds replay suppression to the preceding 50 user messages",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 80,
messages: [
openCodeMessage("msg_001", "user", "repeat me", 1_700_000_000_000),
...Array.from({ length: 50 }, (_, index) => ({
info: {
id: `msg_${String(index + 2).padStart(3, "0")}`,
role: "user",
time: { created: 1_700_000_001_000 + index },
},
parts: [
{
id: `part-${String(index)}`,
type: "text",
text: "internal",
synthetic: true,
},
],
})),
openCodeMessage("msg_052", "user", "repeat me", 1_700_000_052_000),
],
};
await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 1, lastHumanMessageId: "msg_001" })]),
).resolves.toEqual([
expect.objectContaining({
humanTurns: 1,
nextMarker: { seq: 80, lastHumanMessageId: "msg_052" },
}),
]);
},
);
it.runIf(process.platform !== "win32")(
"dedupes SessionSummary re-publication of an existing human message id",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 7,
messages: [openCodeMessage("msg_001", "user", "already reported", 1_700_000_000_000)],
};
const fixture = await installStatefulOpenCode([session]);
const outcomes = await checkOpenCodeUpstreamActivity([
probe({ seq: 6, lastHumanMessageId: "msg_001" }),
]);
expect(outcomes).toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 7, lastHumanMessageId: "msg_001" },
},
]);
await fixture.clearLog();
await expect(
checkOpenCodeUpstreamActivity([probe(markerFrom(outcomes[0]))]),
).resolves.toEqual([]);
expect((await fixture.readCalls()).filter((args) => args[1] === "export")).toHaveLength(0);
},
);
it.runIf(process.platform !== "win32")(
"suppresses ignored, synthetic, shell, compaction, and continuation user rows",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 12,
messages: [
openCodeMessage("msg_001", "assistant", "ready", 1_700_000_000_000),
{
info: { id: "msg_002", role: "user", time: { created: 1_700_000_001_000 } },
parts: [{ id: "part-ignored", type: "text", text: "hidden", ignored: true }],
},
{
info: { id: "msg_003", role: "user", time: { created: 1_700_000_002_000 } },
parts: [{ id: "part-synthetic", type: "text", text: "hidden", synthetic: true }],
},
openCodeMessage(
"msg_004",
"user",
"The following tool was executed by the user",
1_700_000_003_000,
),
{
info: { id: "msg_005", role: "user", time: { created: 1_700_000_004_000 } },
parts: [
{ id: "part-text", type: "text", text: "looks human" },
{ id: "part-compaction", type: "compaction" },
],
},
{
info: { id: "msg_006", role: "user", time: { created: 1_700_000_005_000 } },
parts: [
{
id: "part-continue",
type: "text",
text: "continue",
metadata: { compaction_continue: true },
},
],
},
],
};
await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 1, lastHumanMessageId: null })]),
).resolves.toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 12, lastHumanMessageId: null },
},
]);
},
);
it.runIf(process.platform !== "win32")("does not report a file-mention-only turn", async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 3,
messages: [
openCodeMessage("msg_001", "assistant", "ready", 1_700_000_000_000),
{
info: { id: "msg_002", role: "user", time: { created: 1_700_000_001_000 } },
parts: [
{ id: "part-text", type: "text", text: "@notes.ts" },
{
id: "part-file",
type: "file",
mime: "text/plain",
filename: "notes.ts",
source: { text: { value: "@notes.ts", start: 0, end: 9 } },
},
],
},
],
};
await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 1, lastHumanMessageId: null })]),
).resolves.toEqual([
{
kind: "activity",
sessionKey: "agent:main:ses-a",
humanTurns: 0,
nextMarker: { seq: 3, lastHumanMessageId: null },
},
]);
});
it.runIf(process.platform !== "win32")(
"keeps real text mixed with ignored text and suppresses OpenClaw self-echo",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 4,
messages: [
{
info: { id: "msg_001", role: "user", time: { created: 1_700_000_001_000 } },
parts: [
{ id: "part-hidden", type: "text", text: "hidden", ignored: true },
{ id: "part-real", type: "text", text: "real external turn" },
],
},
],
};
const fixture = await installStatefulOpenCode([session]);
await expect(
checkOpenCodeUpstreamActivity([probe({ seq: 0, lastHumanMessageId: null })]),
).resolves.toEqual([expect.objectContaining({ humanTurns: 1 })]);
await expect(
checkOpenCodeUpstreamActivity([
probe({ seq: 0, lastHumanMessageId: null }, ["real external turn"]),
]),
).resolves.toEqual([
expect.objectContaining({
humanTurns: 0,
nextMarker: { seq: 4, lastHumanMessageId: "msg_001" },
}),
]);
session.messages.push(openCodeMessage("msg_002", "assistant", "summary", 1_700_000_002_000));
session.seq = 5;
await fixture.writeState({ sessions: [session] });
const suppressed = await checkOpenCodeUpstreamActivity([
probe({ seq: 4, lastHumanMessageId: "msg_001" }),
]);
expect(suppressed).toEqual([expect.objectContaining({ humanTurns: 0 })]);
},
);
it.runIf(process.platform !== "win32")(
"reports confirmed absence but not query or export failures",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
seq: 2,
messages: [openCodeMessage("msg_001", "user", "external", 1_700_000_001_000)],
};
const fixture = await installStatefulOpenCode([session]);
const currentProbe = probe({ seq: 1, lastHumanMessageId: null });
await fixture.writeState({ sessions: [], failDb: true });
await expect(checkOpenCodeUpstreamActivity([currentProbe])).resolves.toEqual([]);
await fixture.writeState({ sessions: [session], failExports: ["ses_a"] });
await expect(checkOpenCodeUpstreamActivity([currentProbe])).resolves.toEqual([]);
await fixture.writeState({ sessions: [] });
await expect(checkOpenCodeUpstreamActivity([currentProbe])).resolves.toEqual([
{ kind: "missing", sessionKey: "agent:main:ses-a" },
]);
},
);
it.runIf(process.platform !== "win32")(
"treats a missing event_sequence row as sequence zero",
async () => {
const session: StatefulOpenCodeSession = {
id: "ses_a",
title: "Session A",
directory: "/workspace/a",
messages: [],
};
await installStatefulOpenCode([session]);
await expect(linkContinuedOpenCodeSession("agent:main:ses-a", "ses_a")).resolves.toEqual({
sessionKey: "agent:main:ses-a",
upstream: {
kind: "opencode-cli",
ref: { threadId: "ses_a" },
marker: { seq: 0, lastHumanMessageId: null },
},
});
},
);
it.runIf(process.platform !== "win32")(
"bounds concurrent exports when many cursors advance",
async () => {
const sessions: StatefulOpenCodeSession[] = Array.from({ length: 9 }, (_, index) => ({
id: `ses_${String(index)}`,
title: `Session ${String(index)}`,
directory: `/workspace/${String(index)}`,
seq: 2,
messages: [
openCodeMessage(`msg_${String(index)}`, "assistant", "ready", 1_700_000_001_000 + index),
],
}));
const fixture = await installStatefulOpenCode(sessions);
await fixture.writeState({ sessions, exportDelayMs: 250 });
await expect(
checkOpenCodeUpstreamActivity(
sessions.map((session) => ({
...probe({ seq: 1, lastHumanMessageId: null }),
sessionKey: `agent:main:${session.id}`,
threadId: session.id,
upstreamRef: { threadId: session.id },
})),
),
).resolves.toHaveLength(sessions.length);
expect(await fixture.readMaxExportConcurrency()).toBeGreaterThan(0);
expect(await fixture.readMaxExportConcurrency()).toBeLessThanOrEqual(4);
},
);
});
@@ -0,0 +1,429 @@
import {
isExternalUserText,
normalizeUserText,
type SessionCatalogContinueProviderResult,
type SessionUpstreamActivity,
type SessionUpstreamProbe,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENCODE_SESSION_ID_PATTERN } from "./session-catalog-shared.js";
import { exportOpenCodeSession, queryOpenCodeDatabase } from "./session-catalog.js";
type OpenCodeIndicator = {
threadId: string;
seq: number;
};
type OpenCodeExportPart = {
type: string;
text?: string;
synthetic?: boolean;
ignored?: boolean;
metadata?: Record<string, unknown>;
mime?: string;
filename?: string;
sourceText?: {
value: string;
start: number;
end: number;
};
};
type OpenCodeExportMessage = {
id: string;
role: string;
parts: OpenCodeExportPart[];
createdAt?: number;
};
type OpenCodeMarker = {
seq: number;
lastHumanMessageId: string | null;
};
const OPENCODE_EXPORT_CONCURRENCY = 4;
const OPENCODE_REPLAY_LOOKBACK_USER_MESSAGES = 50;
const OPENCODE_SHELL_SENTINEL = "The following tool was executed by the user";
async function mapConcurrent<T, R>(
values: T[],
limit: number,
mapper: (value: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = [];
results.length = values.length;
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex++;
results[index] = await mapper(values[index]!);
}
});
await Promise.all(workers);
return results;
}
function sqlString(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function readProbeThreadId(probe: SessionUpstreamProbe): string | undefined {
if (
probe.hostId !== "gateway" ||
probe.upstreamKind !== "opencode-cli" ||
!isRecord(probe.upstreamRef) ||
probe.upstreamRef.threadId !== probe.threadId ||
!OPENCODE_SESSION_ID_PATTERN.test(probe.threadId)
) {
return undefined;
}
return probe.threadId;
}
function readMarker(probe: SessionUpstreamProbe): OpenCodeMarker | undefined {
if (!isRecord(probe.marker)) {
return undefined;
}
return Number.isSafeInteger(probe.marker.seq) &&
Number(probe.marker.seq) >= 0 &&
(probe.marker.lastHumanMessageId === null ||
typeof probe.marker.lastHumanMessageId === "string")
? {
seq: Number(probe.marker.seq),
lastHumanMessageId: probe.marker.lastHumanMessageId,
}
: undefined;
}
async function readIndicators(threadIds: string[]): Promise<Map<string, OpenCodeIndicator>> {
if (threadIds.length === 0) {
return new Map();
}
const query = [
"SELECT s.id AS id, es.seq AS seq",
"FROM session AS s",
"LEFT JOIN event_sequence AS es ON es.aggregate_id = s.id",
`WHERE s.id IN (${threadIds.map(sqlString).join(", ")})`,
].join(" ");
const value = await queryOpenCodeDatabase(query);
if (!Array.isArray(value)) {
throw new Error("OpenCode returned invalid upstream indicators");
}
const indicators = new Map<string, OpenCodeIndicator>();
for (const row of value) {
const seq = isRecord(row) && row.seq === null ? 0 : isRecord(row) ? row.seq : undefined;
if (
!isRecord(row) ||
typeof row.id !== "string" ||
!OPENCODE_SESSION_ID_PATTERN.test(row.id) ||
!Number.isSafeInteger(seq) ||
Number(seq) < 0
) {
throw new Error("OpenCode returned invalid upstream indicators");
}
indicators.set(row.id, { threadId: row.id, seq: Number(seq) });
}
return indicators;
}
function readExportPart(value: unknown): OpenCodeExportPart | undefined {
if (!isRecord(value) || typeof value.type !== "string") {
return undefined;
}
const sourceText =
isRecord(value.source) &&
isRecord(value.source.text) &&
typeof value.source.text.value === "string" &&
typeof value.source.text.start === "number" &&
Number.isFinite(value.source.text.start) &&
typeof value.source.text.end === "number" &&
Number.isFinite(value.source.text.end)
? {
value: value.source.text.value,
start: value.source.text.start,
end: value.source.text.end,
}
: undefined;
return {
type: value.type,
...(typeof value.text === "string" ? { text: value.text } : {}),
...(typeof value.synthetic === "boolean" ? { synthetic: value.synthetic } : {}),
...(typeof value.ignored === "boolean" ? { ignored: value.ignored } : {}),
...(isRecord(value.metadata) ? { metadata: value.metadata } : {}),
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
...(sourceText ? { sourceText } : {}),
};
}
function visibleTextPart(
part: OpenCodeExportPart,
sourceRanges: NonNullable<OpenCodeExportPart["sourceText"]>[],
): string | undefined {
if (
part.type !== "text" ||
part.text === undefined ||
part.synthetic === true ||
part.ignored === true ||
part.metadata?.compaction_continue === true
) {
return undefined;
}
let text = part.text;
for (const source of sourceRanges) {
if (
Number.isInteger(source.start) &&
Number.isInteger(source.end) &&
source.start >= 0 &&
source.end >= source.start &&
source.end <= text.length &&
text.slice(source.start, source.end) === source.value
) {
text = text.slice(0, source.start) + text.slice(source.end);
}
}
return text;
}
function messageSourceRanges(
message: OpenCodeExportMessage,
): NonNullable<OpenCodeExportPart["sourceText"]>[] {
return message.parts
.flatMap((part) => (part.sourceText ? [part.sourceText] : []))
.toSorted((left, right) => right.start - left.start);
}
function visibleTextParts(message: OpenCodeExportMessage): string[] {
const sourceRanges = messageSourceRanges(message);
return message.parts.flatMap((part) => {
const text = visibleTextPart(part, sourceRanges);
return text === undefined ? [] : [text];
});
}
function readExportMessages(value: unknown): OpenCodeExportMessage[] {
if (!isRecord(value) || !Array.isArray(value.messages)) {
throw new Error("OpenCode returned an invalid session export");
}
return value.messages
.flatMap((message): OpenCodeExportMessage[] => {
if (!isRecord(message) || !isRecord(message.info) || !Array.isArray(message.parts)) {
return [];
}
const id = message.info.id;
const role = message.info.role;
if (typeof id !== "string" || typeof role !== "string") {
return [];
}
const createdAt =
isRecord(message.info.time) &&
typeof message.info.time.created === "number" &&
Number.isFinite(message.info.time.created)
? message.info.time.created
: undefined;
return [
{
id,
role,
parts: message.parts.flatMap((part) => {
const parsed = readExportPart(part);
return parsed ? [parsed] : [];
}),
...(createdAt === undefined ? {} : { createdAt }),
},
];
})
.toSorted(
(left, right) =>
(left.createdAt ?? Number.NEGATIVE_INFINITY) -
(right.createdAt ?? Number.NEGATIVE_INFINITY) || left.id.localeCompare(right.id),
);
}
function normalizedMessageText(message: OpenCodeExportMessage): string | undefined {
if (message.role !== "user") {
return undefined;
}
const sourceRanges = messageSourceRanges(message);
const texts = message.parts.flatMap((part) => {
const visibleText = visibleTextPart(part, sourceRanges);
if (visibleText !== undefined) {
return [visibleText];
}
if (
part.type === "file" &&
part.mime !== undefined &&
(part.mime.startsWith("image/") || part.mime === "application/pdf")
) {
return [`[Attached ${part.mime}: ${part.filename ?? "file"}]`];
}
return [];
});
return texts.length > 0 ? normalizeUserText(texts.join("\n")) : undefined;
}
function directHumanText(message: OpenCodeExportMessage): string | undefined {
if (
message.role !== "user" ||
message.parts.some((part) => part.type === "compaction") ||
message.parts.some((part) => part.metadata?.compaction_continue === true)
) {
return undefined;
}
const texts = visibleTextParts(message);
if (texts.length === 0) {
return undefined;
}
const text = normalizeUserText(texts.join("\n"));
return !text || text === OPENCODE_SHELL_SENTINEL ? undefined : text;
}
function latestMessageId(current: string | null, candidate: string): string {
return current === null || candidate > current ? candidate : current;
}
function latestBaselineHumanMessageId(messages: OpenCodeExportMessage[]): string | null {
let latest: string | null = null;
for (const message of messages) {
const text = directHumanText(message);
if (text !== undefined) {
latest = latestMessageId(latest, message.id);
}
}
return latest;
}
function classifyExport(params: {
probe: SessionUpstreamProbe;
marker: OpenCodeMarker;
seq: number;
messages: OpenCodeExportMessage[];
}): SessionUpstreamActivity {
let humanTurns = 0;
let occurredAt: number | undefined;
let lastHumanMessageId = params.marker.lastHumanMessageId;
let latestExternalMessageId: string | undefined;
const earlierUserTexts: Array<string | undefined> = [];
for (const message of params.messages) {
const text = directHumanText(message);
const replay =
text !== undefined &&
earlierUserTexts.slice(-OPENCODE_REPLAY_LOOKBACK_USER_MESSAGES).includes(text);
const newerThanMarker =
params.marker.lastHumanMessageId === null || message.id > params.marker.lastHumanMessageId;
if (text !== undefined && message.createdAt !== undefined && newerThanMarker) {
// Consume complete user-shaped rows even when replay/self-echo filtering suppresses
// them, so a later own-text window cannot turn an old row into new activity.
lastHumanMessageId = latestMessageId(lastHumanMessageId, message.id);
}
if (
text !== undefined &&
!replay &&
message.createdAt !== undefined &&
newerThanMarker &&
isExternalUserText(params.probe, text)
) {
humanTurns += 1;
occurredAt = Math.max(occurredAt ?? 0, message.createdAt);
latestExternalMessageId = message.id;
}
if (message.role === "user") {
earlierUserTexts.push(normalizedMessageText(message));
if (earlierUserTexts.length > OPENCODE_REPLAY_LOOKBACK_USER_MESSAGES) {
earlierUserTexts.shift();
}
}
}
const nextMarker = { seq: params.seq, lastHumanMessageId };
return {
kind: "activity",
sessionKey: params.probe.sessionKey,
humanTurns,
nextMarker,
...(humanTurns > 0
? {
occurredAt: occurredAt ?? Date.now(),
dedupeId: latestExternalMessageId ?? String(params.seq),
}
: {}),
};
}
export async function linkContinuedOpenCodeSession(
sessionKey: string,
threadId: string,
): Promise<SessionCatalogContinueProviderResult> {
try {
const indicator = (await readIndicators([threadId])).get(threadId);
if (!indicator) {
return { sessionKey };
}
const messages = readExportMessages(await exportOpenCodeSession(threadId));
return {
sessionKey,
upstream: {
kind: "opencode-cli",
ref: { threadId },
marker: {
seq: indicator.seq,
lastHumanMessageId: latestBaselineHumanMessageId(messages),
},
},
};
} catch {
// Liveness metadata is optional; continuation success must survive baseline failure.
return { sessionKey };
}
}
async function classifyChangedProbe(
probe: SessionUpstreamProbe,
indicator: OpenCodeIndicator,
): Promise<SessionUpstreamActivity | undefined> {
const marker = readMarker(probe);
if (!marker || indicator.seq === marker.seq) {
return undefined;
}
if (indicator.seq < marker.seq) {
// OpenCode migrations may reset event_sequence while preserving the session.
return {
kind: "activity",
sessionKey: probe.sessionKey,
humanTurns: 0,
nextMarker: { seq: indicator.seq, lastHumanMessageId: marker.lastHumanMessageId },
};
}
return classifyExport({
probe,
marker,
seq: indicator.seq,
messages: readExportMessages(await exportOpenCodeSession(probe.threadId)),
});
}
export async function checkOpenCodeUpstreamActivity(
probes: SessionUpstreamProbe[],
): Promise<SessionUpstreamActivity[]> {
const eligible = probes.flatMap((probe) => (readProbeThreadId(probe) ? [probe] : []));
let indicators: Map<string, OpenCodeIndicator>;
try {
indicators = await readIndicators([...new Set(eligible.map((probe) => probe.threadId))]);
} catch {
// A failed batch read confirms nothing about whether any thread still exists.
return [];
}
const outcomes = await mapConcurrent(
eligible,
OPENCODE_EXPORT_CONCURRENCY,
async (probe): Promise<SessionUpstreamActivity | undefined> => {
const indicator = indicators.get(probe.threadId);
if (!indicator) {
return { kind: "missing", sessionKey: probe.sessionKey };
}
try {
return await classifyChangedProbe(probe, indicator);
} catch {
// Export failures are transient reads, not evidence that a thread was deleted.
return undefined;
}
},
);
return outcomes.filter((outcome): outcome is SessionUpstreamActivity => outcome !== undefined);
}