mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
* feat(opencode-plugin): support the OpenCode v2 plugin API OpenCode 2 does not run v1 hook plugins. Keep the existing server() entrypoint and add setup() so one package serves both, adapting MCP, recall, capture, and lifecycle events to the v2 shapes. Refs #5226 * fix(opencode-plugin): align v2 lifecycle handling * docs(opencode-plugin): document v2 support * fix(opencode-plugin): preserve capture across v2 compaction * fix(opencode-plugin): drop reasoning and keep state on failed v2 executions v2 capture turned reasoning blocks into assistant text, so chain-of-thought reached memory extraction and inflated pending tokens. v1 parts and the shared capture filter drop reasoning; v2 now does the same. A failed v2 execution ends one turn, not the session. Mapping it to v1's session.error committed and deleted the session state, so a later capture without a cursor resent every earlier turn. Failed executions now take the same idle path as succeeded and interrupted ones. * fix(opencode-plugin): scope v2 capture to the plugin location and persist its cursor One OpenCode v2 service runs a plugin instance per location, and the plugin event stream carries every location's events. Each instance therefore captured and committed every session, storing each message once per open project. Handle only lifecycle events whose session belongs to this location, resolved from session.created or ctx.session.get because execution events carry no envelope location. The capture cursor lived only in memory, and the shared session state file is rewritten by every instance, so a reloaded or evicted instance could resend the transcript since the last compaction. Keep the cursor in plugin storage and remove it when the session is deleted. * docs(opencode-plugin): describe v2 commit points and location scoping --------- Co-authored-by: Trent Telfer <4094016+ttelfer@users.noreply.github.com>
293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
import test from "node:test"
|
|
import assert from "node:assert/strict"
|
|
import { captureV2Context, injectV2Context, prepareV2Prompt, startV2Plugin } from "../lib/v2-plugin.mjs"
|
|
import { createVikingUriNotice } from "../lib/viking-uri-guard.mjs"
|
|
|
|
function runtimeFixture(overrides = {}) {
|
|
const events = []
|
|
return {
|
|
events,
|
|
runtime: {
|
|
config: { mcp: { enabled: true }, recall: { enabled: true } },
|
|
sessionManager: {
|
|
handleEvent: async (event) => events.push(event),
|
|
flushSession: async () => {},
|
|
flushAll: async () => {},
|
|
},
|
|
repoContext: {
|
|
getRepoSystemPrompt: () => null,
|
|
refreshRepos: async () => {},
|
|
},
|
|
recall: { buildRelevantMemories: async () => undefined },
|
|
sessionInject: { buildSessionContext: async () => undefined },
|
|
vikingUriGuard: async () => {},
|
|
vikingUriNotice: async () => {},
|
|
ready: Promise.resolve(),
|
|
...overrides,
|
|
},
|
|
}
|
|
}
|
|
|
|
function contextFixture(messages = []) {
|
|
const hooks = {}
|
|
const registered = []
|
|
return {
|
|
hooks,
|
|
registered,
|
|
ctx: {
|
|
location: { directory: "/tmp/project" },
|
|
mcp: {
|
|
transform: async (callback) => callback({
|
|
get: () => undefined,
|
|
set: (name, config) => registered.push([name, config]),
|
|
}),
|
|
},
|
|
tool: { hook: async (name, fn) => { hooks[name] = fn } },
|
|
session: {
|
|
hook: async (name, fn) => { hooks[name] = fn },
|
|
context: async () => messages,
|
|
},
|
|
event: { subscribe: () => (async function* empty() {})() },
|
|
},
|
|
}
|
|
}
|
|
|
|
test("startV2Plugin registers direct MCP tools and all v2 hooks", async () => {
|
|
const { runtime } = runtimeFixture()
|
|
const { ctx, hooks, registered } = contextFixture()
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
|
|
assert.equal(registered[0][0], "openviking")
|
|
assert.equal(registered[0][1].type, "local")
|
|
assert.equal(registered[0][1].codemode, false)
|
|
assert.deepEqual(Object.keys(hooks).sort(), ["compaction", "context", "execute.after", "execute.before", "prompt"])
|
|
await cleanup()
|
|
})
|
|
|
|
test("compaction captures messages before the host drops them and contains read failures", async () => {
|
|
const { runtime, events } = runtimeFixture()
|
|
const messages = [
|
|
{ id: "user", type: "user", text: "run the build" },
|
|
{ id: "assistant", type: "assistant", content: [
|
|
{ type: "tool", id: "build", name: "shell", state: { status: "completed", input: { command: "make" }, content: [{ type: "text", text: "built" }] } },
|
|
] },
|
|
]
|
|
const { ctx, hooks } = contextFixture(messages)
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
await hooks.compaction({ sessionID: "ses_1" })
|
|
assert.equal(events.filter((event) => event.type === "message.updated").length, 2)
|
|
assert.equal(events.at(-1).properties.part.output, "built")
|
|
messages.length = 0
|
|
await hooks.compaction({ sessionID: "ses_1" })
|
|
assert.equal(events.filter((event) => event.type === "message.updated").length, 2)
|
|
ctx.session.context = async () => { throw new Error("host unavailable") }
|
|
await assert.doesNotReject(() => hooks.compaction({ sessionID: "ses_1" }))
|
|
await cleanup()
|
|
})
|
|
|
|
test("shell notice preserves multiple text and media blocks without duplicating output", async () => {
|
|
const { runtime } = runtimeFixture({ vikingUriNotice: createVikingUriNotice() })
|
|
const { ctx, hooks } = contextFixture()
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
const original = [
|
|
{ type: "text", text: "command output", metadata: { source: "stdout" } },
|
|
{ type: "image", data: "test" },
|
|
{ type: "text", text: "Exited with code 1" },
|
|
]
|
|
const result = { content: structuredClone(original) }
|
|
await hooks["execute.after"]({ status: "completed", tool: "shell", input: { command: "cat viking://resources/test" }, result })
|
|
assert.deepEqual(result.content.slice(0, 3), original)
|
|
assert.equal(result.content.length, 4)
|
|
assert.match(result.content[3].text, /openviking_read/)
|
|
assert.doesNotMatch(result.content[3].text, /Exited with code 1/)
|
|
await cleanup()
|
|
})
|
|
|
|
test("cleanup waits for background initialization before flushing", async () => {
|
|
let completeBackground
|
|
let flushed = false
|
|
const background = new Promise((resolve) => { completeBackground = resolve })
|
|
const { runtime } = runtimeFixture({ background })
|
|
runtime.sessionManager.flushAll = async () => { flushed = true }
|
|
const { ctx } = contextFixture()
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
const cleaning = cleanup()
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
assert.equal(flushed, false)
|
|
completeBackground()
|
|
await cleaning
|
|
assert.equal(flushed, true)
|
|
})
|
|
|
|
test("prompt metadata is persisted and context injection is stable across model steps", async () => {
|
|
let recallCalls = 0
|
|
const event = {
|
|
sessionID: "ses_1",
|
|
messageID: "msg_1",
|
|
prompt: { text: "find the deployment notes" },
|
|
metadata: { caller: "test" },
|
|
}
|
|
await prepareV2Prompt(event, {
|
|
directory: "/tmp/project",
|
|
recallEnabled: true,
|
|
sessionInject: { buildSessionContext: async () => "<profile>profile</profile>" },
|
|
recall: {
|
|
buildRelevantMemories: async (_input, parts) => {
|
|
recallCalls += 1
|
|
assert.equal(parts[0].text, "find the deployment notes")
|
|
return "<openviking-context>memory</openviking-context>"
|
|
},
|
|
},
|
|
})
|
|
assert.equal(recallCalls, 1)
|
|
assert.equal(event.metadata.caller, "test")
|
|
|
|
const message = { role: "user", content: [{ type: "text", text: event.prompt.text }], metadata: event.metadata }
|
|
const context = { system: [], messages: [message] }
|
|
const repoContext = { getRepoSystemPrompt: () => "repo prompt" }
|
|
injectV2Context(context, repoContext)
|
|
injectV2Context(context, repoContext)
|
|
|
|
assert.equal(message.content.length, 2)
|
|
assert.match(message.content[0].text, /<profile>profile<\/profile>/)
|
|
assert.match(message.content[0].text, /<openviking-context>memory<\/openviking-context>/)
|
|
assert.equal(message.content[0].metadata.openviking, true)
|
|
assert.equal(context.system.length, 1)
|
|
})
|
|
|
|
test("captureV2Context advances a per-session cursor and preserves tool parts", async () => {
|
|
const events = []
|
|
const messages = [
|
|
{ id: "msg_user", type: "user", text: "question" },
|
|
{
|
|
id: "msg_assistant",
|
|
type: "assistant",
|
|
content: [
|
|
{ type: "text", text: "answer" },
|
|
{
|
|
type: "tool", id: "call_1", name: "read",
|
|
state: { status: "completed", input: { path: "README.md" }, content: [{ type: "text", text: "body" }] },
|
|
},
|
|
],
|
|
},
|
|
]
|
|
const ctx = { session: { context: async () => messages } }
|
|
const manager = { handleEvent: async (event) => events.push(event) }
|
|
const cursors = new Map()
|
|
|
|
await captureV2Context(ctx, manager, cursors, "ses_1")
|
|
assert.equal(events.filter((event) => event.type === "message.updated").length, 2)
|
|
assert.equal(events.at(-1).properties.part.tool, "read")
|
|
assert.equal(cursors.get("ses_1"), "msg_assistant")
|
|
|
|
events.length = 0
|
|
await captureV2Context(ctx, manager, cursors, "ses_1")
|
|
assert.deepEqual(events, [])
|
|
})
|
|
|
|
test("v2 prompt, context, after-tool, and event failures are contained", async () => {
|
|
const { runtime } = runtimeFixture({
|
|
recall: { buildRelevantMemories: async () => { throw new Error("recall failed") } },
|
|
vikingUriNotice: async () => { throw new Error("notice failed") },
|
|
})
|
|
const { ctx, hooks } = contextFixture()
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
|
|
const prompt = { sessionID: "ses_1", messageID: "msg_1", prompt: { text: "query" } }
|
|
runtime.sessionInject.buildSessionContext = async () => "<profile>still available</profile>"
|
|
await assert.doesNotReject(() => hooks.prompt(prompt))
|
|
assert.equal(prompt.metadata.openviking.context[0], "<profile>still available</profile>")
|
|
await assert.doesNotReject(() => hooks.context({ sessionID: "ses_1", messages: null, system: [] }))
|
|
await assert.doesNotReject(() => hooks["execute.after"]({
|
|
status: "completed", tool: "shell", input: { command: "ls" }, result: { content: [] },
|
|
}))
|
|
await cleanup()
|
|
})
|
|
|
|
function eventLoopFixture({ events, contexts, locations, storage = new Map() }) {
|
|
const contextCalls = []
|
|
return {
|
|
contextCalls,
|
|
storage,
|
|
ctx: {
|
|
location: { directory: "/repo/a" },
|
|
session: {
|
|
hook: async () => {},
|
|
context: async ({ sessionID }) => {
|
|
contextCalls.push(sessionID)
|
|
return contexts[sessionID] ?? []
|
|
},
|
|
get: async ({ sessionID }) => ({ data: { id: sessionID, location: { directory: locations[sessionID] } } }),
|
|
},
|
|
storage: {
|
|
get: async (key) => storage.get(key),
|
|
set: async (key, value) => { storage.set(key, value) },
|
|
remove: async (key) => { storage.delete(key) },
|
|
},
|
|
event: { subscribe: () => (async function* replay() { yield* events })() },
|
|
},
|
|
}
|
|
}
|
|
|
|
const conversation = [
|
|
{ id: "msg_user", type: "user", text: "question" },
|
|
{ id: "msg_assistant", type: "assistant", content: [{ type: "text", text: "answer" }] },
|
|
]
|
|
|
|
test("event loop handles only sessions that belong to this location", async () => {
|
|
const { runtime, events: handled } = runtimeFixture()
|
|
const { ctx, contextCalls } = eventLoopFixture({
|
|
events: [
|
|
{ type: "session.created", data: { sessionID: "ses_a", location: { directory: "/repo/a" } } },
|
|
{ type: "session.created", data: { sessionID: "ses_b", location: { directory: "/repo/b" } } },
|
|
{ type: "session.execution.succeeded", data: { sessionID: "ses_a" } },
|
|
{ type: "session.execution.succeeded", data: { sessionID: "ses_b" } },
|
|
{ type: "session.execution.succeeded", data: { sessionID: "ses_c" } },
|
|
{ type: "session.execution.failed", data: { sessionID: "ses_a", error: { message: "rate limited" } } },
|
|
],
|
|
contexts: { ses_a: conversation, ses_b: conversation, ses_c: conversation },
|
|
locations: { ses_c: "/repo/b" },
|
|
})
|
|
const cleanup = await startV2Plugin(ctx, runtime, { pluginRoot: "/tmp/ov" })
|
|
await cleanup()
|
|
|
|
assert.deepEqual(contextCalls, ["ses_a", "ses_a"])
|
|
const sessionOf = (event) => event.properties.info?.sessionID ?? event.properties.part?.sessionID
|
|
assert.ok(handled.every((event) => sessionOf(event) === "ses_a"))
|
|
assert.deepEqual(
|
|
handled.filter((event) => event.type.startsWith("session.")).map((event) => event.type),
|
|
["session.created", "session.idle", "session.idle"],
|
|
)
|
|
assert.equal(handled.filter((event) => event.type === "message.updated").length, 2)
|
|
})
|
|
|
|
test("a new plugin instance resumes from the stored capture cursor", async () => {
|
|
const storage = new Map()
|
|
const first = runtimeFixture()
|
|
const firstLoop = eventLoopFixture({
|
|
events: [{ type: "session.execution.succeeded", data: { sessionID: "ses_a" } }],
|
|
contexts: { ses_a: conversation },
|
|
locations: { ses_a: "/repo/a" },
|
|
storage,
|
|
})
|
|
await (await startV2Plugin(firstLoop.ctx, first.runtime, { pluginRoot: "/tmp/ov" }))()
|
|
assert.equal(first.events.filter((event) => event.type === "message.updated").length, 2)
|
|
|
|
const second = runtimeFixture()
|
|
const followUp = { id: "msg_follow_up", type: "user", text: "next question" }
|
|
const secondLoop = eventLoopFixture({
|
|
events: [
|
|
{ type: "session.execution.succeeded", data: { sessionID: "ses_a" } },
|
|
{ type: "session.deleted", data: { sessionID: "ses_a" } },
|
|
],
|
|
contexts: { ses_a: [...conversation, followUp] },
|
|
locations: { ses_a: "/repo/a" },
|
|
storage,
|
|
})
|
|
await (await startV2Plugin(secondLoop.ctx, second.runtime, { pluginRoot: "/tmp/ov" }))()
|
|
|
|
const captured = second.events.filter((event) => event.type === "message.updated")
|
|
assert.deepEqual(captured.map((event) => event.properties.info.id), ["msg_follow_up"])
|
|
assert.equal(storage.size, 0)
|
|
})
|