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.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// @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')
|
||||
})
|
||||
Reference in New Issue
Block a user