openclaw/extensions/opencode/session-upstream-activity.ts
Ayaan Zaidi beadb8c83a
fix: restore native session browsing with OpenCode v2 (#158056)
Closes #158009

## What Problem This Solves

Fixes empty native OpenCode session catalogs and failed transcript/activity reads after upgrading the OpenCode CLI to v2.

## User Impact

OpenCode sessions remain browsable with either CLI generation, and failed CLI calls now leave a useful Gateway warning instead of only a generic catalog error. No configuration or stored-data migration is required.

## Why This Change Was Made

Detect the installed CLI major version. Keep the v1 database/export commands; use v2's global session API, session export, and bounded event-head probe. Normalize the v2 transcript at the plugin boundary so browsing and upstream human-turn detection share the same interpretation. V2 calls use a private standalone server, an empty temporary config directory, and disabled project configuration; the restricted subprocess environment remains in place.

V2 listing follows API cursors in batches of at least 100 until it has enough unarchived sessions. The complete scan shares the existing 30-second CLI budget and reports an error if exhausted, rather than returning a false empty catalog.

## Evidence

- Real OpenCode 2.0.16, installed from the official `@opencode/cli` package in an isolated prefix: baseline Gateway `sessions.catalog.list` returned no sessions and `LOCAL_READ_FAILED`; the invoked CLI rejected `--pure` and `--format`. The candidate lists both locally seeded sessions, and `sessions.catalog.read` returns the seeded user text.
- Real installed OpenCode 1.18.32 with isolated data: the same Gateway catalog RPC lists the seeded session before and after the change. Neither global CLI installation nor user OpenCode data was changed; no model inference was used.
- Real v2 activity probe: baseline marker established, one external user turn detected, unchanged cursor suppressed, and a confirmed missing session distinguished from read failure. This supplemental probe invokes the plugin activity owner directly; catalog listing and reading above use the actual Gateway RPC.
- Gateway logs now show `OpenCode catalog CLI failed: Error: Session not found: ses_missing158009` for a failed v2 transcript read.
- Focused catalog and activity suites: 37 tests passed, 30.97 seconds wall time including worker preparation. The v2 catalog regression fails against the unchanged baseline with `OpenCode exited with code 2`.
- V1/v2 transcript, activity cursor, transient failure, and missing-session coverage. Broader checks are left to CI.
- Archived-first real v2 store: two recently archived sessions precede one live session. With `limitPerHost: 1`, the earlier candidate returned an empty Gateway catalog; the repaired candidate follows the API cursor and returns the live session. The archived-first regression also fails before the repair and passes afterward.
- Archived-heavy real v2 fixture: 100 archived sessions precede one live session. The actual Gateway `sessions.catalog.list` request with `limitPerHost: 1` returned the live session in 2,430 ms. The regression also verifies that exhausting the shared scan deadline produces an error rather than an empty catalog.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-25 16:39:20 +05:30

470 lines
15 KiB
TypeScript

import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
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,
isOpenCodeV2,
queryOpenCodeDatabase,
runOpenCodeApi,
} 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";
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();
}
if (await isOpenCodeV2()) {
const { results } = await runTasksWithConcurrency({
tasks: threadIds.map((threadId) => async (): Promise<OpenCodeIndicator | undefined> => {
let output: string;
try {
output = await runOpenCodeApi("session.log", [
`sessionID=${threadId}`,
"follow=false",
// log.synced carries the current head even when no events follow this cursor.
`after=${Number.MAX_SAFE_INTEGER}`,
]);
} catch (error) {
if (error instanceof Error && typeof error.cause === "string") {
let response: unknown;
try {
response = JSON.parse(error.cause);
} catch {
throw error;
}
if (
isRecord(response) &&
response["_tag"] === "SessionNotFoundError" &&
response.sessionID === threadId
) {
return undefined;
}
}
throw error;
}
for (const line of output.split("\n")) {
if (!line.startsWith("data: ")) {
continue;
}
const event: unknown = JSON.parse(line.slice(6));
if (
isRecord(event) &&
event.type === "log.synced" &&
event.aggregateID === threadId &&
Number.isSafeInteger(event.seq) &&
Number(event.seq) >= 0
) {
return { threadId, seq: Number(event.seq) };
}
}
throw new Error("OpenCode returned invalid upstream indicators");
}),
limit: OPENCODE_EXPORT_CONCURRENCY,
throwOnError: true,
});
return new Map(
results.flatMap((indicator) => (indicator ? [[indicator.threadId, indicator] as const] : [])),
);
}
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 { results: outcomes } = await runTasksWithConcurrency({
tasks: eligible.map((probe) => async (): 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;
}
}),
limit: OPENCODE_EXPORT_CONCURRENCY,
throwOnError: true,
});
return outcomes.filter((outcome): outcome is SessionUpstreamActivity => outcome !== undefined);
}