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.
116 lines
7.6 KiB
TypeScript
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')
|
|
})
|