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,138 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import type { StoredSupervisionResult } from './supervisor-service'
|
||||
import type { StoryGraphToolName } from '../../shared/story-graph-tools'
|
||||
import { readStoryGraph } from './story-graph-reader'
|
||||
|
||||
const cleanups: Array<() => void | Promise<void>> = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
|
||||
type Page = { items: Array<{ object_ref: { type: string; id: string }; result_id: string | null; event_time: string | null;
|
||||
content: string; source_reference_ids: string[]; validity: string }>; page: { next_cursor: string | null; has_more: boolean; total_count: number } }
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'story-graph-'))
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }))
|
||||
const path = join(root, 'assistant.sqlite')
|
||||
const db = new AssistantDatabase(path)
|
||||
db.initialize(root)
|
||||
cleanups.push(() => db.close())
|
||||
const sql = new DatabaseSync(path)
|
||||
cleanups.push(() => sql.close())
|
||||
const project = db.listProjects()[0]!
|
||||
const other = db.createProject({ name: 'Other', description: '', rootPath: root, defaultWorkMode: 'ask' })
|
||||
const messageId = randomUUID(), conversationId = randomUUID()
|
||||
const body = 'Choose A. Then explicitly revise A to B because of measured latency. Option C is only a proposal. '
|
||||
const update = (content: string, projectId = project.id) => db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Decision', updatedAt: 1 },
|
||||
messages: [{ id: messageId, role: 'user', content, createdAt: Date.parse('2026-09-01T00:00:00Z'), state: 'complete' }] }])
|
||||
update(body)
|
||||
const request: StoredSupervisionResult['request'] = { trigger: 'manual', scope: { kind: 'projects', projectIds: [project.id] },
|
||||
timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } }
|
||||
const save = (description: string, occurredAt: string, persistedId?: string) => {
|
||||
const result: StoredSupervisionResult = { request, candidates: db.listSupervisionCandidates(request), evidence: [{ id: 'quote',
|
||||
sourceType: 'conversation', sourceId: conversationId, title: 'Decision evidence', content: body,
|
||||
occurredAt: '2026-09-01T00:00:00Z', locator: { source: `message:${messageId}`, projectId: project.id, conversationId, messageId, start: 0, end: Array.from(body).length, length: Array.from(body).length } }],
|
||||
output: { summary: description, changeDigest: '', openItems: ['Execution still pending'],
|
||||
entities: [{ id: 'choice', label: 'Decision', description, sourceReferenceIds: ['quote'], ...(persistedId ? { persistedId } : {}) }],
|
||||
events: [{ title: description, description, occurredAt, eventType: 'decision', entityIds: ['choice'], sourceReferenceIds: ['quote'] }],
|
||||
entityChanges: [{ entityId: 'choice', changeType: persistedId ? 'revised' : 'proposed', description, sourceReferenceIds: ['quote'] }], relations: [] } }
|
||||
db.saveSupervisionResult(result)
|
||||
return db.listSupervisionCandidates(request)[0]!.id
|
||||
}
|
||||
const read = (name: StoryGraphToolName, input: Record<string, unknown>) => readStoryGraph(sql, name, input, project.id)
|
||||
return { db, sql, read, save, project, other, request, update, body }
|
||||
}
|
||||
|
||||
it('retains A, explicit revision B and proposal C without latest-wins, with event-time order and complete nested pagination', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.save('Decision A', '2026-09-01T00:00:00Z')
|
||||
f.save('Decision B explicitly revises A; execution pending', '2026-09-03T00:00:00Z', id)
|
||||
f.save('Decision C is an option, not a replacement', '2026-09-04T00:00:00Z', id)
|
||||
f.save('Decision late-recorded discussion', '2026-09-02T09:00:00+09:00', id)
|
||||
const input = { object_ref: { type: 'entity', id }, mode: 'timeline', page_size: 2, content_page_size: 71 }
|
||||
let cursor: string | null = null
|
||||
const fragments = new Map<string, string>()
|
||||
const eventTimes = new Map<string, string>()
|
||||
do {
|
||||
const result = f.read('story_graph_get_context', { ...input, ...(cursor ? { cursor } : {}) }) as Page
|
||||
for (const item of result.items) {
|
||||
const key = `${item.object_ref.type}:${item.object_ref.id}:${item.result_id}`
|
||||
fragments.set(key, (fragments.get(key) ?? '') + item.content)
|
||||
if (item.event_time) eventTimes.set(item.object_ref.id, item.event_time)
|
||||
expect(item.validity).toBe('unknown')
|
||||
}
|
||||
cursor = result.page.next_cursor
|
||||
} while (cursor)
|
||||
const data = [...fragments.values()].map(text => JSON.parse(text))
|
||||
expect(data.filter(item => item.canonical_label).map(item => item.description)).toEqual(expect.arrayContaining([
|
||||
'Decision A', 'Decision B explicitly revises A; execution pending', 'Decision C is an option, not a replacement'
|
||||
]))
|
||||
expect([...eventTimes.values()]).toEqual(['2026-09-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', '2026-09-03T00:00:00.000Z', '2026-09-04T00:00:00.000Z'])
|
||||
expect(data.some(item => item.content?.includes('measured latency'))).toBe(true)
|
||||
const search = f.read('story_graph_search', { query: 'Decision', object_types: ['event'], time_range: { from: '2026-09-02T09:00:00+09:00', to: '2026-09-02T00:00:00Z' } }) as Page
|
||||
expect(search.items).toHaveLength(1)
|
||||
expect(search.items[0]!.event_time).toBe('2026-09-02T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('enforces exact scope, rejects as_of and stale/changed cursors, and never writes during reads', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.save('Decision A', '2026-09-01T00:00:00Z')
|
||||
const input = { query: 'Decision', page_size: 1 }
|
||||
const first = f.read('story_graph_search', input) as Page
|
||||
const before = f.sql.prepare('SELECT total_changes() AS n').get()!.n
|
||||
f.sql.exec('PRAGMA query_only = ON')
|
||||
expect(f.db.readStoryGraph('story_graph_search', input, f.other.id)).toMatchObject({ items: [], coverage: { status: 'unknown' } })
|
||||
expect(f.read('story_graph_search', { ...input, scope: { kind: 'global' } })).toMatchObject({ items: [] })
|
||||
expect(() => f.db.readStoryGraph('story_graph_get_context', { object_ref: { type: 'entity', id } }, f.other.id)).toThrow('scope_mismatch')
|
||||
expect(() => f.read('story_graph_get_context', { object_ref: { type: 'entity', id: 'missing' } })).toThrow('object_not_found')
|
||||
expect(() => f.db.readStoryGraph('story_graph_search', input)).toThrow('scope_required')
|
||||
expect(() => f.read('story_graph_search', { ...input, as_of: '2026-09-01' })).toThrow('unsupported_mode')
|
||||
expect(() => f.read('story_graph_get_context', { object_ref: { type: 'entity', id }, mode: 'as_of' })).toThrow('unsupported_mode')
|
||||
expect(() => f.read('story_graph_search', { ...input, query: 'Other', cursor: first.page.next_cursor })).toThrow('invalid_cursor')
|
||||
expect(f.sql.prepare('SELECT total_changes() AS n').get()!.n).toBe(before)
|
||||
f.sql.exec('PRAGMA query_only = OFF')
|
||||
f.db.applySupervisionEntityAction({ entityId: id, action: 'revise', description: 'Human confirmed preference' })
|
||||
expect(() => f.read('story_graph_search', { ...input, cursor: first.page.next_cursor })).toThrow('stale_cursor')
|
||||
const abort = new AbortController(); abort.abort()
|
||||
expect(() => f.db.readStoryGraph('story_graph_search', input, f.project.id, abort.signal)).toThrow()
|
||||
})
|
||||
|
||||
it('pages Unicode snapshots, reads explicit current versions, and reports deletion and moved scope without substituting history', async () => {
|
||||
const f = await fixture()
|
||||
f.save('Decision A', '2026-09-01T00:00:00Z')
|
||||
const source = f.sql.prepare('SELECT id FROM supervision_sources').get()!
|
||||
f.sql.prepare('UPDATE supervision_sources SET content = ? WHERE id = ?').run('A\u{1f600}B\u{1f601}C', source.id!)
|
||||
const input = { source_reference_id: source.id, page_size: 2 }
|
||||
let cursor: string | null = null, text = ''
|
||||
do {
|
||||
const page = f.read('story_graph_read_source', { ...input, ...(cursor ? { cursor } : {}) }) as { content: string; page: { next_cursor: string | null } }
|
||||
text += page.content; cursor = page.page.next_cursor
|
||||
} while (cursor)
|
||||
expect(text).toBe('A\u{1f600}B\u{1f601}C')
|
||||
const current = f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })
|
||||
expect(current.content).toBe(f.body)
|
||||
// Released incremental references can have messageId without the newer source key.
|
||||
f.sql.prepare("UPDATE supervision_sources SET locator_json = json_remove(locator_json, '$.source') WHERE id = ?").run(source.id!)
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toMatchObject({ content: f.body })
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'version', source_version: current.source_version })).toMatchObject({ content: f.body, content_kind: 'version' })
|
||||
f.update('Changed original')
|
||||
const currentPage = f.read('story_graph_read_source', { ...input, content_kind: 'current' }) as { page: { next_cursor: string } }
|
||||
f.update('Changed again')
|
||||
expect(() => f.read('story_graph_read_source', { ...input, content_kind: 'current', cursor: currentPage.page.next_cursor })).toThrow('stale_cursor')
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'version', source_version: current.source_version })).toMatchObject({ error: 'historical_version_unavailable' })
|
||||
f.update('Moved original', f.other.id)
|
||||
expect(() => f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toThrow('scope_mismatch')
|
||||
expect(() => f.db.readStoryGraph('story_graph_read_source', { source_reference_id: source.id }, f.other.id)).toThrow('scope_mismatch')
|
||||
f.sql.exec('DELETE FROM messages')
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toMatchObject({ error: 'current_source_unavailable' })
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id })).toMatchObject({ content: text, content_kind: 'snapshot' })
|
||||
f.sql.prepare('UPDATE supervision_sources SET source_type = ?, locator_json = ? WHERE id = ?').run('knowledge', JSON.stringify({ length: 999 }), source.id!)
|
||||
expect(f.read('story_graph_read_source', { source_reference_id: source.id })).toMatchObject({ original_source_length: 'unknown', reviewed_source_length: 999 })
|
||||
f.sql.prepare('UPDATE supervision_sources SET locator_json = ? WHERE id = ?').run(JSON.stringify({ detail: 'x'.repeat(100_001) }), source.id!)
|
||||
expect(() => f.read('story_graph_read_source', { source_reference_id: source.id })).toThrow('response_too_large')
|
||||
})
|
||||
Reference in New Issue
Block a user