Files
goodbuddy/src/agent-daemon/story-graph-integration.test.ts
T
mesalogo 66f77b83c2 feat: let agents read supervisor story graphs
Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes.

Make Continue discussion show the complete editable message with a separated destination and shared action layout. Sending uses the visible draft without reattaching removed context.

Validation: existing focused MCP, runtime, controlled Linux Host, discussion component and Electron layout checks plus typechecks; staged diff check passed. Cross-scope fact reuse, historical reconstruction and full package acceptance remain deferred.

Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
2026-09-28 12:38:00 +08:00

116 lines
7.6 KiB
TypeScript

// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { afterEach, expect, it, vi } from 'vitest'
import { AssistantDatabase } from '../main/assistant/assistant-database'
import type { KnowledgeService } from '../main/knowledge/knowledge-service'
import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway'
import { ModelToolProvider } from '../main/agent/model-tool-provider'
import { AgentImageToolMcp } from './image-tool-mcp'
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../main/remote-agent/protocol-remote-runtime-channel'
import { storyGraphToolNames } from '../shared/story-graph-tools'
const cleanups: Array<() => void | Promise<void>> = []
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
function fixture() {
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
cleanups.push(() => db.close())
const projectId = db.listProjects()[0]!.id
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }],
output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }],
events: [], relations: [], entityChanges: [] } })
let enabled = true, assigned = true
const read = vi.fn(db.readStoryGraph.bind(db))
const available = vi.fn(async () => enabled && assigned)
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } })
cleanups.push(() => gateway.dispose())
const wait = new AbortController()
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })!
return { db, projectId, gateway, token, wait, read, available,
disable: () => { enabled = false }, unassign: () => { assigned = false } }
}
async function client(url: string, token?: string) {
const connection = new Client({ name: 'graph-test', version: '1' })
await connection.connect(new StreamableHTTPClientTransport(new URL(url), token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined))
cleanups.push(() => connection.close())
return connection
}
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => {
const f = fixture()
let connection: Client
if (transport === 'local') {
await f.gateway.start()
connection = await client(f.gateway.getEndpoint()!, f.token)
} else {
// Real Agent HTTP adapter, binary messages and Main dispatcher; only the wire carrier is in-process.
const queue: RuntimeProtocolBinaryFrame[] = []
let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
let reject: ((error: Error) => void) | undefined
const channel: RuntimeProtocolBinaryChannel = {
channelId: randomUUID(), channelEpoch: '1',
send: async payload => adapter.onReply(payload),
receive: async () => queue.shift() ?? await new Promise((resolve, fail) => { receive = resolve; reject = fail }),
close: () => reject?.(new Error('closed')), onClose: () => () => {}
}
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', storyGraph: true }, async payload => {
const frame = { payload, sequence: '1', consume: async () => {} }
if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame)
})
const main = new MainImageToolSession(channel, undefined, f.wait.signal, f.gateway.bindRemoteStoryGraph(f.token))
await adapter.start()
cleanups.push(() => { main.close(); adapter.close() })
connection = await client(adapter.url!)
}
const tools = (await connection.listTools()).tools
expect(tools.map(tool => tool.name)).toEqual(storyGraphToolNames)
expect(tools.every(tool => tool.annotations?.readOnlyHint)).toBe(true)
const search = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', page_size: 1 } })
expect(search.isError).not.toBe(true)
const result = JSON.parse((search.content as { text: string }[])[0]!.text)
expect(result.resolved_scope).toEqual({ kind: 'projects', projectIds: [f.projectId] })
expect(result.page.has_more).toBe(true)
const context = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'timeline' } })
expect(context.isError).not.toBe(true)
const reference = (f.db.getSupervisionGraph({}).sources as { id: string }[])[0]!.id
const source = await connection.callTool({ name: 'story_graph_read_source', arguments: { source_reference_id: reference } })
expect(JSON.stringify(source)).toContain('revised to B')
const unsupported = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'as_of' } })
expect(unsupported.isError).toBe(true)
expect(JSON.stringify(unsupported)).toContain('unsupported_mode')
const before = f.read.mock.calls.length
f.disable()
expect((await connection.listTools()).tools).toEqual([])
const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } })
expect(blocked.isError).toBe(true)
expect(f.read).toHaveBeenCalledTimes(before)
})
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
const f = fixture()
const provider = new ModelToolProvider(process.cwd(), [], undefined, f.gateway)
cleanups.push(() => provider.dispose())
const context = { conversationId: 'graph', workMode, knowledgeCapabilityToken: f.token }
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
expect(JSON.stringify(result)).toContain(f.projectId)
f.unassign()
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
})
it('rejects disabled in-flight delivery and cancellation before the synchronous read', async () => {
const f = fixture()
f.available.mockImplementationOnce(async () => { f.wait.abort(); return true })
await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow()
expect(f.read).not.toHaveBeenCalled()
const other = fixture()
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result })
await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable')
})