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.
109 lines
6.5 KiB
TypeScript
109 lines
6.5 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
|
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
|
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
|
import { NativeTerminalClient, type NativeTerminalClientInput } from './native-terminal-client'
|
|
|
|
const roots: string[] = []
|
|
const launches: NativeTerminalLaunch[] = []
|
|
afterEach(async () => {
|
|
await Promise.all(launches.splice(0).map(launch => launch.dispose()))
|
|
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), 'native-terminal-test-'))
|
|
roots.push(root)
|
|
const create = vi.fn(async (_owner, _request, launch: NativeTerminalLaunch | undefined) => {
|
|
launches.push(launch!)
|
|
return { sessionId: 'terminal', state: 'running' } as TerminalSnapshot
|
|
})
|
|
const fetcher = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), { headers: { 'content-type': 'application/json' } }))
|
|
const client = new NativeTerminalClient({
|
|
terminalManager: { create }, rootDirectory: root,
|
|
bundledRuntimePaths: { opencode: process.execPath, continue: process.execPath, ripgrep: '', deepseekHarness: '' }, fetcher
|
|
})
|
|
const profile = { id: 'selected', name: 'Selected model', modelName: 'selected-model', baseUrl: 'https://provider.example/v1', authentication: 'api-key' as const, protocol: 'openai-chat-completions' as const, apiKey: 'provider-secret', requestHeaders: { 'x-provider-option': 'configured' } }
|
|
const input: NativeTerminalClientInput = {
|
|
projectId: 'project', projectName: 'Project', directory: root, runtime: 'opencode', workMode: 'ask',
|
|
settings: { opencodeBinaryPath: '', opencodeModelProfile: profile } as unknown as ResolvedRuntimeSettings
|
|
}
|
|
return { root, client, create, fetcher, input }
|
|
}
|
|
|
|
describe('native terminal client', () => {
|
|
it('bridges successive native requests with the selected model and keeps credentials in Main', async () => {
|
|
const { client, input, create, fetcher, root } = await fixture()
|
|
const first = client.open(1, input)
|
|
expect(client.open(1, input)).toBe(first)
|
|
await first
|
|
expect(create).toHaveBeenCalledOnce()
|
|
const launch = launches[0]!
|
|
expect(JSON.stringify(launch.spawnSpec)).not.toContain('provider-secret')
|
|
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
|
const provider = Object.values(config.provider)[0] as { options: { baseURL: string; apiKey: string } }
|
|
for (let i = 0; i < 2; i++) {
|
|
const response = await fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${provider.options.apiKey}` }, body: JSON.stringify({ model: 'selected-model', messages: [{ role: 'user', content: 'hi' }] }) })
|
|
expect(response.status).toBe(200)
|
|
expect(await response.text()).toContain('ok')
|
|
}
|
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://provider.example/v1/chat/completions')
|
|
const headers = new Headers(fetcher.mock.calls[0]?.[1]?.headers)
|
|
expect(headers.get('authorization')).toBe('Bearer provider-secret')
|
|
expect(headers.get('x-provider-option')).toBe('configured')
|
|
const configDirectory = launch.spawnSpec.env.OPENCODE_CONFIG_DIR!
|
|
const pluginPath = new URL(config.plugin[0])
|
|
const plugin = await import(/* @vite-ignore */ pluginPath.href)
|
|
const hooks = await plugin.default()
|
|
await expect(hooks['tool.execute.before']({ tool: 'read' })).resolves.toBeUndefined()
|
|
await expect(hooks['tool.execute.before']({ tool: 'bash' })).rejects.toThrow('read-only')
|
|
await expect(hooks['tool.execute.before']({ tool: 'mcp_write' })).rejects.toThrow('read-only')
|
|
await launch.dispose()
|
|
await expect(readdir(configDirectory)).rejects.toThrow()
|
|
expect(await readdir(join(root, 'opencode'))).toHaveLength(1)
|
|
await expect(fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST' })).rejects.toThrow()
|
|
await client.open(1, input)
|
|
expect(create).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('releases prepared resources after terminal creation fails and permits retry', async () => {
|
|
const { client, input, create, root } = await fixture()
|
|
create.mockImplementationOnce(async (_owner, _request, launch) => {
|
|
launches.push(launch!)
|
|
throw new Error('PTY unavailable')
|
|
})
|
|
await expect(client.open(1, input)).rejects.toThrow('PTY unavailable')
|
|
expect((await readdir(root)).filter(name => name.startsWith('launch-'))).toEqual([])
|
|
await expect(client.open(1, input)).resolves.toMatchObject({ state: 'running' })
|
|
})
|
|
|
|
it('uses Execute permissions and explicit session MCP endpoints', async () => {
|
|
const { client, input } = await fixture()
|
|
await client.open(1, { ...input, workMode: 'execute', mcpServers: [{ name: 'tools', url: 'http://127.0.0.1:12345/mcp', headers: { Authorization: 'Bearer local-token' } }] })
|
|
const launch = launches[0]!
|
|
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
|
expect(config.permission).toBe('allow')
|
|
expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp')
|
|
expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute')
|
|
})
|
|
|
|
it('allows only the bound read tools through native OpenCode Ask permissions and execution hook', async () => {
|
|
const { client, input } = await fixture()
|
|
await client.open(1, { ...input, mcpServers: [{ name: 'goodbuddy-0', url: 'http://127.0.0.1:12345/mcp', headers: {},
|
|
readOnlyTools: ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source'] }] })
|
|
const config = JSON.parse(launches[0]!.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
|
expect(config.mcp['goodbuddy-0']).toBeDefined()
|
|
const hooks = await (await import(/* @vite-ignore */ new URL(config.plugin[0]).href)).default()
|
|
for (const name of ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']) {
|
|
expect(config.permission[`goodbuddy-0_${name}`]).toBe('allow')
|
|
await expect(hooks['tool.execute.before']({ tool: `goodbuddy-0_${name}` })).resolves.toBeUndefined()
|
|
}
|
|
await expect(hooks['tool.execute.before']({ tool: 'goodbuddy-0_generate_image' })).rejects.toThrow('read-only')
|
|
})
|
|
})
|