diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index 692ec8a..560e4ad 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -175,6 +175,149 @@ function injectEnvelope(messages, item) { return injected; } +function maskMarkdownCode(text) { + const lines = String(text).split(/(\r?\n)/); + let fenceCharacter; + let fenceLength = 0; + return lines + .map((line) => { + if (/^\r?\n$/.test(line)) return line; + const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line); + if (fence) { + const character = fence[1][0]; + if (!fenceCharacter) { + fenceCharacter = character; + fenceLength = fence[1].length; + } else if (character === fenceCharacter && fence[1].length >= fenceLength) { + fenceCharacter = undefined; + fenceLength = 0; + } + return " ".repeat(line.length); + } + if (fenceCharacter) return " ".repeat(line.length); + + const masked = [...line]; + for (let cursor = 0; cursor < line.length; cursor += 1) { + if (line[cursor] !== "`") continue; + let runLength = 1; + while (line[cursor + runLength] === "`") runLength += 1; + const closing = line.indexOf("`".repeat(runLength), cursor + runLength); + if (closing < 0) { + cursor += runLength - 1; + continue; + } + for (let index = cursor; index < closing + runLength; index += 1) masked[index] = " "; + cursor = closing + runLength - 1; + } + return masked.join(""); + }) + .join(""); +} + +// [impl->REQ-PARITY-PEER-SHORTFORM] +export function parsePeerShortforms(text) { + const source = String(text ?? ""); + const prose = maskMarkdownCode(source); + const shortforms = []; + const pattern = /@<([\s\S]*?)@>/g; + for (const match of prose.matchAll(pattern)) { + const inner = match[1]; + const separator = inner.search(/\s/); + if (separator <= 0) continue; + const targetList = inner.slice(0, separator); + const body = inner.slice(separator).trim(); + if (!/^[A-Za-z0-9_-]+(?:,[A-Za-z0-9_-]+)*$/.test(targetList) || !body) continue; + shortforms.push({ + targets: targetList.split(","), + body, + index: match.index, + raw: match[0], + }); + } + return shortforms; +} + +function parseJson(raw, label) { + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`${label} returned invalid JSON: ${errorSummary(error)}`); + } +} + +function parseVersion(raw) { + const match = String(raw ?? "").match(/\bv?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?\b/); + return match ? match.slice(1, 4).map(Number) : undefined; +} + +function compareVersions(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function formatVersion(version) { + return `v${version.join(".")}`; +} + +function promptHints(prompt) { + const text = String(prompt ?? "").trim(); + if (!text) return []; + const hints = []; + if ( + /(?:^|\s)\/live(?:\s|$)/i.test(text) || + /\b(?:go|become|start|resume|activate)\s+(?:an?\s+)?live(?:\s+agent)?\b/i.test(text) + ) { + hints.push("Use extension-native `/live`; use `/live --auto` only for explicit auto-resume."); + } + if ( + /\bwho am i\b/i.test(text) || + /\b(?:show|check|what(?:'s| is))\s+(?:my\s+)?(?:spt\s+)?(?:identity|endpoint|perch)\b/i.test( + text, + ) + ) { + hints.push("Run `spt whoami --json` for this endpoint's identity."); + } + if ( + /\b(?:send|message|contact|tell|reply to)\s+(?:an?\s+|the\s+)?(?:agent|peer|endpoint)\b/i.test( + text, + ) || + /\bhow (?:do|can|should) (?:i|we) (?:send|message|contact)\b/i.test(text) + ) { + hints.push("Run `spt how-to send` for canonical peer messaging."); + } + if ( + /\bspt\s+(?:how-to\s+)?subnet\b/i.test(text) || + /\bsubnet\s+(?:status|create|join|code|onboarding|setup)\b/i.test(text) || + /\b(?:pair|join|connect)\s+(?:these\s+|two\s+)?(?:machines|nodes)\b/i.test(text) + ) { + hints.push("Run `spt how-to subnet` for private-network onboarding."); + } + if ( + /(?:^|\s)\/checkpoint(?:\s|$)/i.test(text) || + /\b(?:make|take|create|use|run|perform|need|want)\b.{0,20}\bcheckpoint\b/i.test(text) || + /\bcheckpoint\s+(?:my|this|the)\s+(?:context|session|work)\b/i.test(text) || + /\bcommune\s+(?:across|to\s+(?:the\s+)?next\s+session)\b/i.test(text) || + /\b(?:reset|compact)\s+(?:my\s+|the\s+)?(?:working\s+)?context\b/i.test(text) + ) { + hints.push("Read the packaged commune skill and use its checkpoint mode."); + } + return hints; +} + +function startupBrief(id) { + return [ + `OMP SPT endpoint \`${id}\` is active. Keep lifecycle infrastructure extension-owned.`, + "- Identity/roster: `spt whoami --json`; `spt endpoint list`.", + "- Messaging: `spt how-to send` (or explicit assistant shortform `@`).", + "- Continuity/lifecycle: use the packaged commune (checkpoint mode), signoff, and role skills.", + "- Activation/setup: `/ready`, `/live`, and the packaged setup skill.", + "- Subnets: `spt how-to subnet`.", + "- Versions/updates: `spt --version`; `spt adapter version omp-spt`; `spt update`.", + ].join("\n"); +} + const DEFAULT_COMMAND_TIMEOUT_MS = 15_000; const DEFAULT_KILL_GRACE_MS = 100; const DEFAULT_KILL_FORCE_MS = 100; @@ -423,10 +566,101 @@ export function createOmpSpt(overrides = {}) { ]; const listenerStableMs = overrides.listenerStableMs === false ? undefined : (overrides.listenerStableMs ?? 30_000); + const checkUpdates = overrides.checkUpdates ?? true; + const updateProbeTimeoutMs = overrides.updateProbeTimeoutMs ?? 1_500; + const fetchLatestAdapterVersion = + overrides.fetchLatestAdapterVersion ?? + (async () => { + const controller = new AbortController(); + const timer = setTimer(() => controller.abort(), updateProbeTimeoutMs); + timer?.unref?.(); + try { + const response = await fetch( + "https://api.github.com/repos/BigscreenVR/omp-spt/releases/latest", + { + headers: { accept: "application/vnd.github+json" }, + signal: controller.signal, + }, + ); + if (!response.ok) return undefined; + return (await response.json())?.tag_name; + } catch { + return undefined; + } finally { + clearTimer(timer); + } + }); + + const checkpointParameters = (pi) => { + const z = pi.zod?.z ?? pi.zod; + return z.object({ + wake: z + .string() + .optional() + .describe("Instruction for the first continuation after native context compaction"), + }); + }; + + const endpointIdPattern = /^[A-Za-z0-9_-]+$/; + + const commandCompletions = (cachedIds, includeAuto) => (prefix) => { + const values = includeAuto ? ["--auto", ...cachedIds] : [...cachedIds]; + const matches = values + .filter((value) => value.startsWith(prefix.trim())) + .map((value) => ({ value, label: value })); + return matches.length > 0 ? matches : null; + }; + + function normalizePath(value) { + return String(value ?? "") + .replaceAll("\\", "/") + .replace(/\/+$/, "") + .toLowerCase(); + } + + function latestDigestTimestamp(digest) { + let latest = Number.NEGATIVE_INFINITY; + for (const turn of digest?.turns ?? []) { + for (const entry of turn?.entries ?? []) { + for (const value of Object.values(entry ?? {})) { + const parsed = Date.parse(value?.ts ?? ""); + if (Number.isFinite(parsed)) latest = Math.max(latest, parsed); + } + } + } + return latest; + } + + function newestCoreUpdate(rawNotifications, currentVersion) { + const notifications = parseJson(rawNotifications, "spt notif list").notifs ?? []; + let newest; + for (const notification of notifications) { + if ( + notification?.from_id !== "spt-update" || + notification?.kind !== "consent" || + notification?.state === "dismissed" + ) { + continue; + } + const version = parseVersion(notification.head); + if (!version || compareVersions(version, currentVersion) <= 0) continue; + if (!newest || compareVersions(version, newest) > 0) newest = version; + } + return newest; + } + return function ompSpt(pi) { - const id = env.SPT_ENDPOINT_ID?.trim(); - if (!id) return; + let id = env.SPT_ENDPOINT_ID?.trim() || undefined; + const initialId = id; + let activationType; + let activationPromise; + let activated = false; + let startupBriefPending = false; + let updateNoticesPromise = Promise.resolve([]); + let updateNoticesPending = true; + let cachedReadyIds = []; + let cachedLiveIds = []; let sid; let token; @@ -526,6 +760,397 @@ export function createOmpSpt(overrides = {}) { activeCommands.delete(controller); }); } + // [impl->REQ-PARITY-UPDATE-NOTICE] + async function detectUpdateNotices() { + const [coreVersionResult, notificationsResult, adapterVersionResult, latestAdapterResult] = + await Promise.allSettled([ + runCommand(["--version"]), + runCommand(["--json", "notif", "list"]), + runCommand(["adapter", "version", ADAPTER]), + fetchLatestAdapterVersion(), + ]); + const notices = []; + + if ( + coreVersionResult.status === "fulfilled" && + notificationsResult.status === "fulfilled" + ) { + const current = parseVersion(coreVersionResult.value); + if (current) { + try { + const latest = newestCoreUpdate(notificationsResult.value, current); + if (latest) { + notices.push( + `spt-core ${formatVersion(latest)} is available; run \`spt update\`.`, + ); + } + } catch (error) { + pi.logger.debug("omp-spt update notification probe skipped", { + error: errorSummary(error), + }); + } + } + } + + if ( + adapterVersionResult.status === "fulfilled" && + latestAdapterResult.status === "fulfilled" + ) { + const current = parseVersion(adapterVersionResult.value); + const latest = parseVersion(latestAdapterResult.value); + if (current && latest && compareVersions(latest, current) > 0) { + notices.push( + `omp-spt ${formatVersion(latest)} is available; run \`spt adapter update omp-spt\`, then restart this endpoint.`, + ); + } + } + return notices; + } + + function beginUpdateProbe() { + updateNoticesPending = true; + updateNoticesPromise = checkUpdates + ? detectUpdateNotices().catch((error) => { + pi.logger.debug("omp-spt update probe skipped", { + error: errorSummary(error), + }); + return []; + }) + : Promise.resolve([]); + } + + async function compatibleCandidates(type, ctx) { + const listing = parseJson( + await runCommand(["--json", "endpoint", "list", "--show-all"]), + "spt endpoint list", + ); + const local = (listing.local ?? []).filter( + (candidate) => + candidate?.state === type && + candidate?.alive !== true && + endpointIdPattern.test(candidate?.id ?? ""), + ); + const currentDirectory = normalizePath(ctx.cwd); + const detailed = await Promise.all( + local.map(async (candidate) => { + try { + const info = parseJson( + await runCommand(["--json", "api", "endpoint-info", candidate.id]), + `spt api endpoint-info ${candidate.id}`, + ); + if (String(info.adapter ?? "").split(":")[0] !== ADAPTER) return undefined; + if ( + currentDirectory && + info.cwd && + normalizePath(info.cwd) !== currentDirectory + ) { + return undefined; + } + return { id: candidate.id, info }; + } catch (error) { + pi.logger.debug("omp-spt activation candidate skipped", { + id: candidate.id, + error: errorSummary(error), + }); + return undefined; + } + }), + ); + const candidates = detailed.filter(Boolean); + const ids = candidates.map((candidate) => candidate.id).sort(); + if (type === "ready_agent") cachedReadyIds.splice(0, cachedReadyIds.length, ...ids); + else cachedLiveIds.splice(0, cachedLiveIds.length, ...ids); + return candidates; + } + + async function chooseIdentity(type, ctx) { + if (!ctx.hasUI) { + ctx.ui.notify( + `/${type === "live_agent" ? "live" : "ready"} requires an endpoint id when native selection is unavailable`, + "error", + ); + return undefined; + } + let candidates; + try { + candidates = await compatibleCandidates(type, ctx); + } catch (error) { + logError("omp-spt could not list compatible endpoint identities", error); + return undefined; + } + const createLabel = "Create a new endpoint id"; + const selected = + candidates.length > 0 + ? await ctx.ui.select( + `Select ${type === "live_agent" ? "live" : "ready"} endpoint`, + [...candidates.map((candidate) => candidate.id), createLabel], + ) + : createLabel; + if (!selected) return undefined; + if (selected !== createLabel) return selected; + return (await ctx.ui.input("New SPT endpoint id", "letters, numbers, - or _"))?.trim(); + } + + // [impl->REQ-PARITY-LIVE-AUTO-RESUME] + async function chooseAutoResume(ctx) { + if (!ctx.hasUI) { + ctx.ui.notify("`/live --auto` requires native confirmation UI", "error"); + return undefined; + } + let candidates; + try { + candidates = await compatibleCandidates("live_agent", ctx); + } catch (error) { + logError("omp-spt could not list live auto-resume candidates", error); + return undefined; + } + const recent = ( + await Promise.all( + candidates.map(async (candidate) => { + try { + const digest = parseJson( + await runCommand([ + "--json", + "endpoint", + "digest", + candidate.id, + "--last", + "1", + ]), + `spt endpoint digest ${candidate.id}`, + ); + return { + ...candidate, + lastActiveAt: latestDigestTimestamp(digest), + }; + } catch { + return { ...candidate, lastActiveAt: Number.NEGATIVE_INFINITY }; + } + }), + ) + ).filter((candidate) => Number.isFinite(candidate.lastActiveAt)); + recent.sort( + (left, right) => + right.lastActiveAt - left.lastActiveAt || left.id.localeCompare(right.id), + ); + if (recent.length === 0) { + ctx.ui.notify( + "No compatible prior omp-spt live identity has recorded activity; use `/live `", + "error", + ); + return undefined; + } + let candidate = recent[0]; + const ties = recent.filter((item) => item.lastActiveAt === candidate.lastActiveAt); + if (ties.length > 1) { + const selected = await ctx.ui.select( + "Select equally recent live endpoint", + ties.map((item) => item.id), + ); + if (!selected) return undefined; + candidate = ties.find((item) => item.id === selected); + } + const confirmed = await ctx.ui.confirm( + "Resume live endpoint?", + `${candidate.id} was the most recently active compatible live endpoint (${new Date( + candidate.lastActiveAt, + ).toISOString()}). Bind this OMP session to it?`, + ); + return confirmed ? candidate.id : undefined; + } + + // [impl->REQ-PARITY-READY-ACTIVATION] + // [impl->REQ-PARITY-LIVE-ACTIVATION] + async function activateEndpoint(nextId, type, ctx, options = {}) { + if (!endpointIdPattern.test(nextId ?? "")) { + ctx.ui.notify( + "SPT endpoint ids may contain only letters, numbers, `-`, and `_`", + "error", + ); + return false; + } + if (activated || token) { + ctx.ui.notify( + `This OMP session is immutably bound to ${id}; stop it before activating another identity`, + "warning", + ); + return false; + } + if (stopping) { + ctx.ui.notify("This OMP session is already shutting down", "error"); + return false; + } + if (activationPromise) return activationPromise; + + runtimeCtx = ctx; + ui = ctx.ui; + sid = ctx.sessionManager.getSessionId(); + id = nextId; + activationType = type; + let bound = false; + const operation = (async () => { + const bindArgs = [ + "api", + "--adapter", + ADAPTER, + "bind", + id, + "--set-session-id", + sid, + ]; + if (type) bindArgs.push("--type", type); + if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); + bindPromise = (async () => { + const response = await runCommand(bindArgs); + token = response.match(/\btoken=([^\s]+)/)?.[1]; + if (!token) throw new Error("spt bind response did not include token="); + bound = true; + })(); + try { + await bindPromise; + await syncDesiredState(); + if (stopping) { + await teardownSession("OMP session shut down before initialization completed"); + return false; + } + activated = true; + startupBriefPending = true; + ui.setStatus("omp-spt", `spt:${id}`); + startListener(); + beginUpdateProbe(); + if (options.announce) { + ui.notify( + `OMP session activated as ${type === "live_agent" ? "live" : "ready"} endpoint ${id}`, + "info", + ); + } + return true; + } catch (error) { + ui.setStatus("omp-spt", "spt bind failed"); + if (options.fatal || bound) { + await failClosed(`omp-spt could not bind ${id}`, error); + return false; + } + pi.logger.error(`omp-spt could not bind ${id}`, { + error: errorSummary(error), + }); + ui.notify(`omp-spt could not bind ${id}: ${errorSummary(error)}`, "error"); + id = undefined; + activationType = undefined; + bindPromise = undefined; + token = undefined; + return false; + } + })(); + activationPromise = operation; + try { + return await operation; + } finally { + if (!activated && activationPromise === operation) activationPromise = undefined; + } + } + + async function handleActivationCommand(type, args, ctx) { + const command = type === "live_agent" ? "live" : "ready"; + const trimmed = args.trim(); + let nextId; + if (trimmed === "--auto") { + if (type !== "live_agent") { + ctx.ui.notify("`--auto` is supported only by `/live`", "error"); + return; + } + nextId = await chooseAutoResume(ctx); + } else if (!trimmed) { + nextId = await chooseIdentity(type, ctx); + } else if (/\s/.test(trimmed) || trimmed.startsWith("-")) { + ctx.ui.notify(`Usage: /${command} ${command === "live" ? " | --auto" : ""}`, "error"); + return; + } else { + nextId = trimmed; + } + if (!nextId) return; + await activateEndpoint(nextId, type, ctx, { announce: true }); + } + pi.registerCommand("ready", { + description: "Activate this OMP session as a ready SPT endpoint", + getArgumentCompletions: commandCompletions(cachedReadyIds, false), + handler: (args, ctx) => handleActivationCommand("ready_agent", args, ctx), + }); + + pi.registerCommand("live", { + description: "Activate this OMP session as a live SPT endpoint", + getArgumentCompletions: commandCompletions(cachedLiveIds, true), + handler: (args, ctx) => handleActivationCommand("live_agent", args, ctx), + }); + + // [impl->REQ-PARITY-CHECKPOINT] + pi.registerTool({ + name: "spt_checkpoint", + label: "SPT Checkpoint", + description: + "After the commune skill has saved this live endpoint's continuity drop, compact native OMP context and wake the same endpoint.", + parameters: checkpointParameters(pi), + async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) { + if (!activated || !id) { + return { + content: [{ type: "text", text: "SPT checkpoint failed: no active endpoint." }], + details: { ok: false, reason: "not-activated" }, + isError: true, + }; + } + if (activationType === "ready_agent") { + return { + content: [ + { + type: "text", + text: "SPT checkpoint failed: continuity checkpoints require a live endpoint.", + }, + ], + details: { ok: false, reason: "not-live" }, + isError: true, + }; + } + const wake = + parameters.wake?.trim() || + "Resume from the saved commune context and continue the prior work."; + try { + await ctx.compact({ + internalGuidance: + "Preserve only the durable state needed to continue from the just-saved SPT commune.", + }); + pi.sendMessage( + { + customType: "omp-spt-checkpoint-wake", + content: wake, + display: true, + attribution: "user", + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + return { + content: [ + { + type: "text", + text: `SPT checkpoint complete for ${id}; native continuation queued.`, + }, + ], + details: { ok: true, endpoint: id }, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `SPT checkpoint failed: ${errorSummary(error)}`, + }, + ], + details: { ok: false, reason: errorSummary(error) }, + isError: true, + }; + } + }, + }); + function abortActiveCommands(reason, allowBindGrace = false) { for (const [controller, active] of activeCommands) { @@ -1024,49 +1649,13 @@ export function createOmpSpt(overrides = {}) { runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); - const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid]; - if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); - bindPromise = (async () => { - const bound = await runCommand(bindArgs); - token = bound.match(/\btoken=([^\s]+)/)?.[1]; - if (!token) throw new Error("spt bind response did not include token="); - })(); - try { - await bindPromise; - } catch (error) { - if (stopping) return; - ui.setStatus("omp-spt", "spt bind failed"); - await failClosed(`omp-spt could not bind ${id}`, error); - return; - } - if (stopping) { - try { - await teardownSession("OMP session shut down before initialization completed"); - } catch (error) { - logError("omp-spt session teardown failed", error); - } - return; - } - try { - await syncDesiredState(); - if (stopping) { - await teardownSession("OMP session shut down before initialization completed"); - return; - } - ui.setStatus("omp-spt", `spt:${id}`); - startListener(); - } catch (error) { - if (stopping) { - logError("omp-spt session teardown failed", error); - return; - } - ui.setStatus("omp-spt", "spt bind failed"); - await failClosed(`omp-spt could not bind ${id}`, error); - } + if (!initialId) return; + await activateEndpoint(initialId, undefined, ctx, { fatal: true }); }); // [impl->REQ-OMP-SESSION-IMMUTABLE] const blockSessionChange = (description, ctx) => { + if (!activated && !token) return; ctx.ui.notify( `omp-spt blocked the in-TUI ${description}; end this SPT session first`, "warning", @@ -1082,19 +1671,101 @@ export function createOmpSpt(overrides = {}) { ); // [impl->REQ-OMP-MESSAGE-CONTEXT] + // [impl->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] pi.on("context", (event) => { - if (!current?.submitted || current.settling) return; - const messages = injectEnvelope(event.messages, current); + let messages = event.messages; + if ( + agentActive && + !current && + !dispatching && + queue.length > 0 + ) { + const item = queue.shift(); + item.stub = senderStub(item.from ?? "unknown"); + item.submitted = true; + item.activeInjected = true; + current = item; + messages = [ + ...messages, + { + role: "user", + content: `${item.stub}\n\n${item.envelope}`, + }, + ]; + } + if (current?.submitted && !current.settling && !current.activeInjected) { + messages = injectEnvelope(messages, current); + } if (messages !== event.messages) return { messages }; }); + // [impl->REQ-PARITY-STARTUP-BRIEF] + // [impl->REQ-PARITY-TARGETED-HINTS] + // [impl->REQ-PARITY-UPDATE-NOTICE] + pi.on("before_agent_start", async (event) => { + if (!activated || !id || stopping) return; + const additions = []; + if (startupBriefPending) { + startupBriefPending = false; + additions.push(startupBrief(id)); + } + const hints = promptHints(event.prompt); + if (hints.length > 0) additions.push(`OMP SPT targeted hints:\n- ${hints.join("\n- ")}`); + if (updateNoticesPending) { + updateNoticesPending = false; + const notices = await updateNoticesPromise; + if (notices.length > 0) additions.push(`OMP SPT updates:\n- ${notices.join("\n- ")}`); + } + if (additions.length === 0) return; + return { + systemPrompt: [...(event.systemPrompt ?? []), additions.join("\n\n")], + }; + }); + + // [impl->REQ-PARITY-PEER-SHORTFORM] + async function dispatchShortforms(messages, afterUserMessage) { + const assistantOutput = extractReply(messages, afterUserMessage); + const shortforms = parsePeerShortforms(assistantOutput); + if (shortforms.length === 0) return; + const statuses = []; + for (const shortform of shortforms) { + for (const target of shortform.targets) { + if (!id) { + statuses.push(`${target}: failed (activate this OMP session first)`); + continue; + } + try { + const result = await runCommand(["send", target, "--from", id], shortform.body); + statuses.push(`${target}: ${firstLine(result) || "sent"}`); + } catch (error) { + statuses.push(`${target}: failed (${errorSummary(error)})`); + } + } + } + try { + pi.sendMessage( + { + customType: "omp-spt-peer-status", + content: `OMP SPT peer-message results:\n- ${statuses.join("\n- ")}`, + display: true, + attribution: "user", + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + } catch (error) { + logError("omp-spt could not inject peer-message results", error); + } + } + + // [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function completeTurn(event) { agentActive = false; desiredState = "idle"; if (stopping) return; + const messages = event.messages ?? []; const completed = current; if (completed?.submitted && !completed.settled) { - const reply = extractReply(event.messages, completed.stub); + const reply = extractReply(messages, completed.stub); try { await settleItem( completed, @@ -1109,6 +1780,7 @@ export function createOmpSpt(overrides = {}) { return; } } + await dispatchShortforms(messages, completed?.stub); if (current === completed) { current = undefined; releaseItem(completed); diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs index 8fbe0c2..52cded3 100644 --- a/tests/omp-extension.mjs +++ b/tests/omp-extension.mjs @@ -5,6 +5,7 @@ import { decodeBody, drainEvents, extractReply, + parsePeerShortforms, runSpt, } from "../adapter/strings/omp-spt.mjs"; @@ -100,9 +101,33 @@ function createHarness(options = {}) { const notifications = []; const errors = []; const debug = []; + const commands = new Map(); + const tools = new Map(); + const sentMessages = []; + const selections = []; + const confirmations = []; + const inputs = []; + const compactions = []; const clock = new FakeClock(); let shutdowns = 0; + const fakeSchema = { + describe() { + return this; + }, + optional() { + return this; + }, + }; + const z = { + object(shape) { + return { ...fakeSchema, shape }; + }, + string() { + return { ...fakeSchema }; + }, + }; + const ui = { notify(message, type) { notifications.push({ message, type }); @@ -110,15 +135,34 @@ function createHarness(options = {}) { setStatus(key, text) { statuses.push({ key, text }); }, + async select(title, values) { + selections.push({ title, values }); + return options.onSelect?.(title, values) ?? options.selectResults?.shift(); + }, + async confirm(title, message) { + confirmations.push({ title, message }); + return options.onConfirm?.(title, message) ?? options.confirmResults?.shift() ?? false; + }, + async input(title, placeholder) { + inputs.push({ title, placeholder }); + return options.onInput?.(title, placeholder) ?? options.inputResults?.shift(); + }, }; const ctx = { ui, + hasUI: options.hasUI ?? true, + cwd: options.cwd ?? "C:\\work\\project", + async compact(compactionOptions) { + compactions.push(compactionOptions); + return options.onCompact?.(compactionOptions); + }, sessionManager: { getSessionId: () => options.sessionId ?? "session-1" }, shutdown() { shutdowns += 1; }, }; const pi = { + zod: { z }, logger: { error(message, details) { errors.push({ message, details }); @@ -132,6 +176,16 @@ function createHarness(options = {}) { registered.push(handler); handlers.set(name, registered); }, + registerCommand(name, command) { + commands.set(name, command); + }, + registerTool(tool) { + tools.set(tool.name, tool); + }, + sendMessage(message, delivery) { + sentMessages.push({ message, delivery }); + options.onSendMessage?.(message, delivery); + }, sendUserMessage(content) { submitted.push(content); options.onSubmit?.(content); @@ -158,10 +212,12 @@ function createHarness(options = {}) { }; const extension = createOmpSpt({ env: { - SPT_ENDPOINT_ID: options.id ?? "omp-agent", + SPT_ENDPOINT_ID: Object.hasOwn(options, "id") ? options.id : "omp-agent", OMP_SPT_SUBNET: options.subnet, OMP_SPT_SPT_BIN: "spt-test", }, + checkUpdates: options.checkUpdates ?? false, + fetchLatestAdapterVersion: options.fetchLatestAdapterVersion, acceptedBytesLimit: options.acceptedBytesLimit, acceptedQueueLimit: options.acceptedQueueLimit, restartDelaysMs: options.restartDelaysMs ?? [5, 10], @@ -190,17 +246,24 @@ function createHarness(options = {}) { } return { + commands, + confirmations, + compactions, calls, children, clock, ctx, debug, emit, + inputs, errors, handlers, notifications, + selections, + sentMessages, statuses, submitted, + tools, get shutdowns() { return shutdowns; }, @@ -288,6 +351,7 @@ async function testLifecycleCustodyAndContext() { "session_before_switch", "session_before_branch", "context", + "before_agent_start", "agent_start", "agent_end", "session_stop", @@ -1355,6 +1419,493 @@ async function testShutdownFallbackStaysBelowHostCap() { assert.deepEqual(hardCapHarness.clock.delays(), []); } +// [unit->REQ-PARITY-READY-ACTIVATION] +// [unit->REQ-PARITY-LIVE-ACTIVATION] +async function testNativeActivationCommandsAndErrors() { + const inert = createHarness({ id: null }); + assert.deepEqual([...inert.commands.keys()], ["ready", "live"]); + assert.ok(inert.tools.has("spt_checkpoint")); + await inert.emit("session_start"); + assert.deepEqual(inert.calls, [], "an ordinary OMP session must remain lifecycle-inert"); + assert.equal( + await inert.emit("session_before_switch", { reason: "new" }), + undefined, + "an unbound extension must not block native session changes", + ); + + await inert.commands.get("ready").handler("ready-one", inert.ctx); + const readyBind = inert.calls.find((call) => call.args[3] === "bind"); + assert.deepEqual(readyBind.args, [ + "api", + "--adapter", + "omp-spt", + "bind", + "ready-one", + "--set-session-id", + "session-1", + "--type", + "ready_agent", + ]); + assert.deepEqual(inert.children[0].args, [ + "api", + "--adapter", + "omp-spt", + "listen", + "ready-one", + "--session-id", + "session-1", + ]); + assert.ok( + inert.notifications.some(({ message, type }) => + type === "info" && message.includes("ready endpoint ready-one"), + ), + ); + await inert.commands.get("live").handler("other-id", inert.ctx); + assert.equal(inert.calls.filter((call) => call.args[3] === "bind").length, 1); + assert.ok( + inert.notifications.some(({ message }) => message.includes("immutably bound to ready-one")), + ); + await inert.emit("session_shutdown"); + + let bindAttempts = 0; + const retryable = createHarness({ + id: null, + onRun(call) { + if (call.args[3] === "bind") { + bindAttempts += 1; + if (bindAttempts === 1) throw new Error("identity already active"); + } + }, + }); + await retryable.emit("session_start"); + await retryable.commands.get("live").handler("retry-live", retryable.ctx); + assert.equal(retryable.children.length, 0); + assert.equal(retryable.shutdowns, 0, "a pre-token command error must keep ordinary OMP usable"); + assert.ok( + retryable.notifications.some(({ message }) => message.includes("identity already active")), + ); + await retryable.commands.get("live").handler("retry-live", retryable.ctx); + assert.equal(retryable.children.length, 1, "a corrected activation may retry after a bind error"); + assert.ok( + retryable.calls + .findLast((call) => call.args[3] === "bind") + .args.includes("live_agent"), + ); + await retryable.emit("session_shutdown"); + + const headless = createHarness({ id: null, hasUI: false }); + await headless.emit("session_start"); + await headless.commands.get("ready").handler("", headless.ctx); + assert.deepEqual(headless.calls, []); + assert.ok( + headless.notifications.some(({ message }) => message.includes("requires an endpoint id")), + "ordinary activation must never guess an identity when native selection is unavailable", + ); + await headless.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-READY-ACTIVATION] +async function testNativeActivationSelectionAndCompletion() { + const harness = createHarness({ + id: null, + selectResults: ["ready-old"], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint") { + return JSON.stringify({ + local: [ + { id: "ready-old", state: "ready_agent", alive: false }, + { id: "ready-busy", state: "ready_agent", alive: true }, + { id: "live-old", state: "live_agent", alive: false }, + ], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: call.args[3], + adapter: "omp-spt", + cwd: "C:\\work\\project", + }); + } + }, + }); + await harness.emit("session_start"); + await harness.commands.get("ready").handler("", harness.ctx); + assert.deepEqual(harness.selections[0].values, [ + "ready-old", + "Create a new endpoint id", + ]); + assert.equal(harness.calls.find((call) => call.args[3] === "bind").args[4], "ready-old"); + assert.deepEqual(harness.commands.get("ready").getArgumentCompletions("ready-"), [ + { value: "ready-old", label: "ready-old" }, + ]); + assert.ok( + !harness.calls.some((call) => call.args[3] === "poll"), + "selection and activation must not start an agent-managed background poll", + ); + await harness.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-LIVE-AUTO-RESUME] +async function testExplicitLiveAutoResume() { + const activity = { + old: "2026-07-10T00:00:00.000Z", + newest: "2026-07-15T00:00:00.000Z", + }; + const harness = createHarness({ + id: null, + confirmResults: [true], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint" && call.args[2] === "list") { + return JSON.stringify({ + local: [ + { id: "old", state: "live_agent", alive: false }, + { id: "newest", state: "live_agent", alive: false }, + { id: "active", state: "live_agent", alive: true }, + { id: "foreign", state: "live_agent", alive: false }, + ], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: call.args[3], + adapter: call.args[3] === "foreign" ? "claude-spt" : "omp-spt", + cwd: "C:\\work\\project", + }); + } + if (call.args[0] === "--json" && call.args[2] === "digest") { + const id = call.args[3]; + return JSON.stringify({ + turns: [{ entries: [{ Agent: { ts: activity[id], text: id } }] }], + }); + } + }, + }); + await harness.emit("session_start"); + assert.deepEqual(harness.commands.get("live").getArgumentCompletions("--"), [ + { value: "--auto", label: "--auto" }, + ]); + await harness.commands.get("live").handler("--auto", harness.ctx); + assert.match(harness.confirmations[0].message, /newest.*most recently active/s); + const bind = harness.calls.find((call) => call.args[3] === "bind"); + assert.equal(bind.args[4], "newest"); + assert.ok(bind.args.includes("live_agent")); + assert.ok( + !harness.calls.some( + (call) => call.args[2] === "digest" && ["active", "foreign"].includes(call.args[3]), + ), + "auto-resume must inspect only inactive compatible identities", + ); + await harness.emit("session_shutdown"); + + const declined = createHarness({ + id: null, + confirmResults: [false], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint" && call.args[2] === "list") { + return JSON.stringify({ + local: [{ id: "prior", state: "live_agent", alive: false }], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: "prior", + adapter: "omp-spt", + cwd: "C:\\work\\project", + }); + } + if (call.args[0] === "--json" && call.args[2] === "digest") { + return JSON.stringify({ + turns: [{ entries: [{ Agent: { ts: "2026-07-15T01:00:00.000Z" } }] }], + }); + } + }, + }); + await declined.emit("session_start"); + await declined.commands.get("live").handler("--auto", declined.ctx); + assert.equal(declined.calls.filter((call) => call.args[3] === "bind").length, 0); + await declined.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-STARTUP-BRIEF] +// [unit->REQ-PARITY-TARGETED-HINTS] +// [unit->REQ-PARITY-UPDATE-NOTICE] +async function testStartupBriefHintsAndUpdateNotices() { + const harness = createHarness({ + checkUpdates: true, + fetchLatestAdapterVersion: async () => "v0.3.0", + onRun(call) { + if (call.args[0] === "--version") return "spt 1.0.0"; + if (call.args[0] === "--json" && call.args[1] === "notif") { + return JSON.stringify({ + notifs: [ + { + from_id: "spt-update", + kind: "consent", + state: "seen:1", + head: "An spt-core update v1.2.0 is available.", + }, + { + from_id: "spt-update", + kind: "consent", + state: "dismissed", + head: "An spt-core update v9.0.0 is available.", + }, + ], + }); + } + if (call.args[0] === "adapter" && call.args[1] === "version") return "0.2.1"; + }, + }); + await harness.emit("session_start"); + const first = await harness.emit("before_agent_start", { + prompt: + "Go live, show my endpoint identity, send a message to an agent, join two machines, and create a checkpoint.", + systemPrompt: ["base"], + }); + assert.equal(first.systemPrompt[0], "base"); + const injected = first.systemPrompt.at(-1); + for (const expected of [ + "spt whoami --json", + "spt endpoint list", + "spt how-to send", + "commune (checkpoint mode), signoff, and role", + "/ready", + "/live", + "spt how-to subnet", + "spt --version", + "spt adapter version omp-spt", + "spt update", + "Use extension-native `/live`", + "omp-spt v0.3.0 is available", + "spt adapter update omp-spt", + "spt-core v1.2.0 is available", + ]) { + assert.match(injected, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + const quiet = await harness.emit("before_agent_start", { + prompt: "Review live reload, identity matrices, message queues, subnet masks, and checkpoint files.", + systemPrompt: ["base"], + }); + assert.equal(quiet, undefined, "related technical nouns without clear SPT intent must not hint"); + await harness.emit("session_shutdown"); + + const unavailable = createHarness({ + checkUpdates: true, + fetchLatestAdapterVersion: async () => { + throw new Error("network down"); + }, + onRun(call) { + if (call.args[0] === "--version") throw new Error("version unavailable"); + if (call.args[0] === "--json" && call.args[1] === "notif") { + throw new Error("notification store unavailable"); + } + if (call.args[0] === "adapter" && call.args[1] === "version") { + throw new Error("adapter version unavailable"); + } + }, + }); + await unavailable.emit("session_start"); + const noClaim = await unavailable.emit("before_agent_start", { + prompt: "Continue the task.", + systemPrompt: [], + }); + assert.ok(noClaim.systemPrompt.at(-1).includes("OMP SPT endpoint")); + assert.ok(!noClaim.systemPrompt.at(-1).includes("OMP SPT updates:")); + await unavailable.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] +// [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] +async function testActiveTurnBoundaryDeliveryAndFallback() { + const harness = createHarness(); + await harness.emit("session_start"); + await harness.emit("agent_start"); + const firstEnvelope = 'one'; + const secondEnvelope = 'two'; + harness.children[0].stdout.emit("data", `${firstEnvelope}${secondEnvelope}`); + await flush(); + assert.deepEqual(harness.submitted, []); + + const boundary = await harness.emit("context", { + messages: [{ role: "user", content: "operator prompt" }], + }); + assert.equal( + boundary.messages.at(-1).content, + `\n\n${firstEnvelope}`, + "the first accepted message must enter at the next model boundary", + ); + await harness.emit("agent_end", { + messages: [...boundary.messages, { role: "assistant", content: "first outcome" }], + }); + assert.equal(commandCalls(harness, "send")[0].args[1], "first"); + assert.equal(commandCalls(harness, "send")[0].input, "first outcome"); + assert.deepEqual(harness.clock.delays(), [0]); + await harness.clock.runNext(0); + assert.deepEqual( + harness.submitted, + [''], + "a later accepted message must preserve order and fall back to an ordinary next turn", + ); + assert.ok( + !harness.calls.some((call) => call.args[3] === "poll"), + "active-turn delivery must use the extension listener, never a background poll", + ); + await harness.emit("session_shutdown"); +} + +// [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] +async function testAbnormalTurnsRestoreReceivability() { + for (const [label, completionEvent] of [ + ["cancelled", "session_stop"], + ["interrupted", "session_stop"], + ["failed", "agent_end"], + ]) { + const harness = createHarness(); + await harness.emit("session_start"); + await harness.emit("agent_start"); + harness.children[0].stdout.emit( + "data", + `work`, + ); + await flush(); + const boundary = await harness.emit("context", { + messages: [{ role: "user", content: "active work" }], + }); + await harness.emit(completionEvent, { messages: boundary.messages }); + const outcome = commandCalls(harness, "send")[0]; + assert.equal(outcome.args[1], label); + assert.match(outcome.input, /turn ended without an assistant response/); + assert.equal(stateCalls(harness).at(-1).args[4], "idle"); + + harness.children[0].stdout.emit( + "data", + `next`, + ); + await flush(); + assert.deepEqual(harness.submitted, [``]); + await harness.emit("session_shutdown"); + } +} + +// [unit->REQ-PARITY-PEER-SHORTFORM] +async function testPeerShortformParsingAndDispatch() { + assert.deepEqual( + parsePeerShortforms("Before @ after @").map( + ({ targets, body }) => ({ targets, body }), + ), + [ + { targets: ["alpha", "beta"], body: "hello there" }, + { targets: ["gamma"], body: "second\nline" }, + ], + ); + assert.deepEqual( + parsePeerShortforms( + "ordinary @alice; `@`\n```\n@\n```\n@", + ), + [], + ); + + const harness = createHarness({ + onRun(call) { + if (call.args[0] === "send" && call.args[1] === "alpha") return "SENT:alpha"; + if (call.args[0] === "send" && call.args[1] === "beta") { + throw new Error("NO_PERCH:beta"); + } + if (call.args[0] === "send" && call.args[1] === "gamma") return "QUEUED:gamma"; + }, + }); + await harness.emit("session_start"); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { + role: "assistant", + content: "@\nDone.\n@", + }, + ], + }); + assert.deepEqual( + commandCalls(harness, "send").map((call) => [call.args[1], call.input]), + [ + ["alpha", "ship the patch"], + ["beta", "ship the patch"], + ["gamma", "inspect release"], + ], + ); + assert.equal(harness.sentMessages.length, 1); + assert.deepEqual(harness.sentMessages[0].delivery, { + deliverAs: "nextTurn", + triggerTurn: true, + }); + assert.match(harness.sentMessages[0].message.content, /alpha: SENT:alpha/); + assert.match(harness.sentMessages[0].message.content, /beta: failed \(NO_PERCH:beta\)/); + assert.match(harness.sentMessages[0].message.content, /gamma: QUEUED:gamma/); + + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { + role: "assistant", + content: "Mention @alpha and quote `@` without side effects.", + }, + ], + }); + assert.equal(commandCalls(harness, "send").length, 3); + assert.equal(harness.sentMessages.length, 1); + await harness.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-CHECKPOINT] +async function testNativeCheckpointTool() { + const harness = createHarness(); + await harness.emit("session_start"); + const tool = harness.tools.get("spt_checkpoint"); + const result = await tool.execute( + "tool-1", + { wake: "Continue release preparation." }, + undefined, + undefined, + harness.ctx, + ); + assert.equal(result.details.ok, true); + assert.equal(harness.compactions.length, 1); + assert.deepEqual(harness.sentMessages[0], { + message: { + customType: "omp-spt-checkpoint-wake", + content: "Continue release preparation.", + display: true, + attribution: "user", + }, + delivery: { deliverAs: "nextTurn", triggerTurn: true }, + }); + await harness.emit("session_shutdown"); + + const ready = createHarness({ id: null }); + await ready.emit("session_start"); + await ready.commands.get("ready").handler("plain-ready", ready.ctx); + const refused = await ready.tools + .get("spt_checkpoint") + .execute("tool-2", {}, undefined, undefined, ready.ctx); + assert.equal(refused.isError, true); + assert.equal(refused.details.reason, "not-live"); + assert.deepEqual(ready.compactions, []); + await ready.emit("session_shutdown"); + + const failed = createHarness({ + onCompact() { + throw new Error("native compaction cancelled"); + }, + }); + await failed.emit("session_start"); + const failure = await failed.tools + .get("spt_checkpoint") + .execute("tool-3", {}, undefined, undefined, failed.ctx); + assert.equal(failure.isError, true); + assert.match(failure.content[0].text, /native compaction cancelled/); + assert.deepEqual(failed.sentMessages, [], "a failed reset must never queue a false wake"); + await failed.emit("session_shutdown"); +} + await testParsingAndReplies(); await testRunSptRejectsStdinErrorsAndHungCommands(); await testLifecycleCustodyAndContext(); @@ -1373,5 +1924,13 @@ await testProtocolCorruptionFailsClosed(); await testInboundQueueOverflowReturnsAcceptedCustody(); await testSessionStopAwaitsOutcomeWithoutEndingEndpoint(); await testShutdownFallbackStaysBelowHostCap(); +await testNativeActivationCommandsAndErrors(); +await testNativeActivationSelectionAndCompletion(); +await testExplicitLiveAutoResume(); +await testStartupBriefHintsAndUpdateNotices(); +await testActiveTurnBoundaryDeliveryAndFallback(); +await testAbnormalTurnsRestoreReceivability(); +await testPeerShortformParsingAndDispatch(); +await testNativeCheckpointTool(); console.log("OMP-EXTENSION OK");