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:
@@ -46,14 +46,19 @@ const server = (name: string) => ({
|
||||
|
||||
// Keep the actual helper and adapter config builder together: mocking the
|
||||
// adapter constructor would miss the model-profile configuration-loss bug.
|
||||
it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => {
|
||||
it('delivers current ACP session MCP capabilities in Execute and Ask with read-only Ask authorization', async () => {
|
||||
const configs: Array<Record<string, unknown>> = []
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
|
||||
operationId: 'operation', workMode: transport.mode
|
||||
})))
|
||||
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (
|
||||
this: ContinueHostAdapter, _prompt, _signal, _authorize, options
|
||||
this: ContinueHostAdapter, _prompt, _signal, authorize, options
|
||||
) {
|
||||
if (options?.workMode === 'ask') {
|
||||
expect(await authorize!({ toolName: 'story_graph_search' } as never)).toBe('once')
|
||||
expect(await authorize!({ toolName: 'generate_image' } as never)).toBe('deny')
|
||||
expect(await authorize!({ toolName: 'write_file' } as never)).toBe('deny')
|
||||
}
|
||||
const path = await (this as unknown as {
|
||||
createRunConfig(options: ContinueHostRunOptions): Promise<string>
|
||||
}).createRunConfig(options!)
|
||||
@@ -83,7 +88,8 @@ it('delivers current ACP session MCP capabilities through the bridged model conf
|
||||
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
|
||||
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
|
||||
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
|
||||
[], []
|
||||
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
|
||||
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], []
|
||||
])
|
||||
expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })])
|
||||
} finally {
|
||||
@@ -92,7 +98,7 @@ it('delivers current ACP session MCP capabilities through the bridged model conf
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => {
|
||||
it('keeps local profile MCP scoping and includes Main-bound session servers in Ask', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
|
||||
try {
|
||||
const configPath = join(root, 'native.json')
|
||||
@@ -112,7 +118,9 @@ it('keeps local profile MCP scoping and filters explicit session servers in Ask
|
||||
const path = await create({ workMode: 'ask', sessionMcpServers: [{
|
||||
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
|
||||
}] })
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers).toEqual([{
|
||||
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
|
||||
}])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { promptWithUntrustedConversationHistory } from '../main/agent/runtime-co
|
||||
import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime'
|
||||
import { createUnixModelBridgeExchange } from './model-bridge-broker'
|
||||
import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper'
|
||||
import { isStoryGraphTool } from '../shared/story-graph-tools'
|
||||
|
||||
export async function runContinueAcpHelper(options: {
|
||||
socketPath: string
|
||||
@@ -88,11 +89,11 @@ export async function runContinueAcpHelper(options: {
|
||||
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
|
||||
}) : undefined
|
||||
const workMode = route?.workMode ?? options.workMode
|
||||
const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => {
|
||||
const sessionMcpServers = session.mcpServers.map(server => {
|
||||
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
|
||||
return { name: server.name, type: 'streamable-http' as const, url: server.url,
|
||||
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
|
||||
}) : []
|
||||
})
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat',
|
||||
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
|
||||
@@ -111,7 +112,7 @@ export async function runContinueAcpHelper(options: {
|
||||
session.active = { abort, adapter, finished: new Promise(resolve => { finish = resolve }) }
|
||||
try {
|
||||
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
|
||||
async () => workMode === 'execute' ? 'once' : 'deny', {
|
||||
async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', {
|
||||
workMode, images, sessionMcpServers,
|
||||
onEvent: async event => {
|
||||
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { imageToolInputSchema, imageToolName } from '../shared/image-generation-contracts'
|
||||
import { storyGraphTools, type StoryGraphToolName } from '../shared/story-graph-tools'
|
||||
import {
|
||||
decodeRemoteImageToolMessage, encodeRemoteImageToolMessage,
|
||||
remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema
|
||||
@@ -44,12 +45,23 @@ export class AgentImageToolMcp {
|
||||
}
|
||||
const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
const mcp = new McpServer({ name: 'goodbuddy-image', version: '1' }, { capabilities: { tools: {} } })
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
|
||||
name: imageToolName, description: this.descriptor.description,
|
||||
inputSchema: schema as { type: 'object' }
|
||||
}] }))
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
const available = this.descriptor.storyGraph ? await this.call({}, 'story_graph_list') : undefined
|
||||
const graphEnabled = available?.content.some(part => part.type === 'text' && JSON.parse(part.text).available === true)
|
||||
return { tools: [
|
||||
...(this.descriptor.description ? [{ name: imageToolName, description: this.descriptor.description, inputSchema: schema as { type: 'object' } }] : []),
|
||||
...(graphEnabled ? storyGraphTools.map(tool => ({ name: tool.name, description: tool.description,
|
||||
inputSchema: z.toJSONSchema(tool.inputSchema, { target: 'draft-7', io: 'input' }) as { type: 'object' },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false } })) : [])
|
||||
] }
|
||||
})
|
||||
mcp.setRequestHandler(CallToolRequestSchema, async (call, extra) => {
|
||||
if (call.params.name !== imageToolName) throw new Error('Unknown image tool')
|
||||
const graphTool = storyGraphTools.find(tool => tool.name === call.params.name)
|
||||
if (graphTool && this.descriptor.storyGraph) {
|
||||
try { return await this.call(call.params.arguments ?? {}, graphTool.name) }
|
||||
catch (error) { return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
|
||||
}
|
||||
if (call.params.name !== imageToolName || !this.descriptor.description) throw new Error('Unknown image tool')
|
||||
const input = imageToolInputSchema.parse(call.params.arguments)
|
||||
// Repeated delivery of an MCP request returns the same outcome, including unknown delivery.
|
||||
const key = `${typeof extra.requestId}:${extra.requestId}`
|
||||
@@ -87,8 +99,8 @@ export class AgentImageToolMcp {
|
||||
const reply = remoteImageToolReplySchema.parse(decodeRemoteImageToolMessage(payload))
|
||||
const pending = this.pending.get(reply.callId)
|
||||
if (!pending) return
|
||||
if (reply.error || !reply.result) pending.reject(new Error(reply.error ?? 'Image tool response has no result'))
|
||||
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.result) }] })
|
||||
if (reply.error || (!reply.result && !reply.storyGraphResult)) pending.reject(new Error(reply.error ?? 'Tool response has no result'))
|
||||
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.storyGraphResult ?? reply.result) }] })
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -100,7 +112,7 @@ export class AgentImageToolMcp {
|
||||
this.server?.close()
|
||||
}
|
||||
|
||||
private async call(input: z.infer<typeof imageToolInputSchema>): Promise<CallToolResult> {
|
||||
private async call(input: Record<string, unknown>, name?: StoryGraphToolName | 'story_graph_list'): Promise<CallToolResult> {
|
||||
if (this.closed) throw new Error('Image tool prompt has ended')
|
||||
const callId = randomUUID()
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
@@ -110,7 +122,7 @@ export class AgentImageToolMcp {
|
||||
timer.unref()
|
||||
})
|
||||
// Register the wait before sending; send failures must not cause a retry.
|
||||
void this.send(encodeRemoteImageToolMessage({ callId, input })).catch(() => {
|
||||
void this.send(encodeRemoteImageToolMessage({ callId, input, ...(name ? { name } : {}) })).catch(() => {
|
||||
this.pending.get(callId)?.reject(new Error('Image tool delivery may be unknown. Do not retry automatically.'))
|
||||
})
|
||||
try { return await result } finally {
|
||||
|
||||
@@ -132,6 +132,9 @@ async function plugin(input?: {
|
||||
permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*'))
|
||||
}
|
||||
permission.push({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
|
||||
if (route.imageToolName) {
|
||||
permission.push({ permission: `${route.imageToolName}_story_graph_*`, pattern: '*', action: 'allow' })
|
||||
}
|
||||
if (route.workMode === 'execute' && route.imageToolName) {
|
||||
permission.push({ permission: `${route.imageToolName}_*`, pattern: '*', action: 'allow' })
|
||||
}
|
||||
|
||||
@@ -634,6 +634,36 @@ describe('RuntimeAcpBackend', () => {
|
||||
expect(fixture.lifecycle).not.toContain('bridge-listen')
|
||||
} finally { await fixture.backend.dispose() }
|
||||
})
|
||||
|
||||
it('binds Story Graph Ask discovery and reads to authenticated blob frames', async () => {
|
||||
const fixture = harness()
|
||||
const client = new Client({ name: 'graph-backend-test', version: '1' })
|
||||
try {
|
||||
await open(fixture)
|
||||
const accepted = await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
imageTool: { channelId: 'graph-channel', channelEpoch: '1', storyGraph: true }
|
||||
})) as { imageToolUrl: string }
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(accepted.imageToolUrl)))
|
||||
const listing = client.listTools()
|
||||
await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledOnce())
|
||||
const reply = async (index: number, storyGraphResult: object) => {
|
||||
const frame = fixture.blobSink.mock.calls[index]![0]
|
||||
const call = remoteImageToolCallSchema.parse(decodeRemoteImageToolMessage(frame.payload))
|
||||
const payload = encodeRemoteImageToolMessage({ callId: call.callId, storyGraphResult })
|
||||
await fixture.backend.onBlobFrame({ header: { ...frame.header, direction: 'main-to-agent', payloadLength: payload.byteLength }, payload },
|
||||
{ ...fixture.context, channelId: 'graph-channel' })
|
||||
return call
|
||||
}
|
||||
expect(await reply(0, { available: true })).toMatchObject({ name: 'story_graph_list' })
|
||||
expect((await listing).tools).toHaveLength(3)
|
||||
const reading = client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })
|
||||
await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledTimes(2))
|
||||
expect(await reply(1, { items: [], coverage: { status: 'unknown' } })).toMatchObject({ name: 'story_graph_search', input: { query: 'Decision' } })
|
||||
expect(await reading).not.toHaveProperty('isError', true)
|
||||
await invoke(fixture, 'runtime/completePrompt', { bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' })
|
||||
await expect(fetch(accepted.imageToolUrl)).rejects.toThrow()
|
||||
} finally { await client.close(); await fixture.backend.dispose() }
|
||||
})
|
||||
it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' })
|
||||
try {
|
||||
|
||||
@@ -1173,7 +1173,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preparation.imageTool && (preparation.workMode !== 'execute' || !this.#options.blobSink)) {
|
||||
if (preparation.imageTool && ((preparation.imageTool.description && preparation.workMode !== 'execute') || !this.#options.blobSink)) {
|
||||
throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity')
|
||||
}
|
||||
if (requestedModelBridgePolicy !== undefined) {
|
||||
|
||||
@@ -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