fix: stabilize Runtime workflows for 0.12.3

OpenCode status checks could cold-start throwaway servers, unrelated
conversations could block each other, and model, Continue, or remote paths
could replay or duplicate work under failure and backlog conditions. Settings
could also lose edits or overwrite unrelated model fields.

This update makes status checks passive, keeps per-conversation ordering while
allowing concurrency, preserves native long-running work with bounded display
capture and backpressure, narrows retries to known pre-dispatch failures, and
aligns remote Ask permissions without reducing Execute. It also keeps
Embedding updates narrow, improves settings and browser guidance, removes the
unused legacy Main gateway state, and records the validated Desktop 0.12.3 and
Agent 0.11.17 release metadata.

Release note: 改善 OpenCode 切换、多会话长任务、模型重试、远程 Ask/Execute 与设置保存可靠性,并完成 Desktop 0.12.3 / Agent 0.11.17 发布准备。
This commit is contained in:
mesalogo
2026-09-04 08:05:29 +08:00
parent 374968d50b
commit 4490e5fd15
60 changed files with 1639 additions and 3839 deletions
+1 -2
View File
@@ -17,7 +17,6 @@ import {
} from '../shared/model-request-policy'
import { canonicalJson } from '../shared/agent-protocol/canonical'
import {
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders
} from '../shared/model-request-customization'
@@ -573,7 +572,7 @@ function prepareProviderRequest(
if (profile.protocol === 'anthropic-messages') {
headers.set('anthropic-version', '2023-06-01')
}
const canonicalHeaders = canonicalModelRequestHeaders(headers)
const canonicalHeaders = Object.fromEntries(headers.entries())
if (profile.protocol === 'anthropic-messages') {
if (profile.authentication === 'api-key') {
headers.set('x-api-key', profile.apiKey!)
+5
View File
@@ -58,6 +58,8 @@ describe('Agent CLI fixed command contract', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
'/runtime/bin/opencode'
],
@@ -69,6 +71,7 @@ describe('Agent CLI fixed command contract', () => {
protocol: 'openai-responses',
model: 'private-model',
supportsImageInput: false,
workMode: 'ask',
opencodeEntrypoint: '/runtime/bin/opencode'
})
await expect(
@@ -83,6 +86,8 @@ describe('Agent CLI fixed command contract', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
'/runtime/bin/opencode',
'--api-key',
+7
View File
@@ -185,6 +185,7 @@ async function runModelBridgeHelper(
'protocol',
'model',
'supports-image-input',
'work-mode',
'opencode-entrypoint'
])
requireOptions(options, [
@@ -192,6 +193,7 @@ async function runModelBridgeHelper(
'protocol',
'model',
'supports-image-input',
'work-mode',
'opencode-entrypoint'
])
const protocol = options.protocol
@@ -206,6 +208,10 @@ async function runModelBridgeHelper(
if (imageInput !== 'true' && imageInput !== 'false') {
throw new Error('Invalid model bridge image-input option')
}
const workMode = options['work-mode']
if (workMode !== 'ask' && workMode !== 'execute') {
throw new Error('Invalid model bridge work-mode option')
}
return await (
dependencies.runModelBridgeHelper ??
runOpenCodeModelBridgeHelper
@@ -214,6 +220,7 @@ async function runModelBridgeHelper(
protocol: protocol as ModelBridgeProtocol,
model: options.model!,
supportsImageInput: imageInput === 'true',
workMode,
opencodeEntrypoint: options['opencode-entrypoint']!
})
}
+21 -2
View File
@@ -44,10 +44,14 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
export type OpenCodeModelBridgeProviderConfig = {
model: string
permission?: 'ask'
agent: {
title: {
disable: true
}
build?: {
permission: 'ask'
}
}
provider: Record<
string,
@@ -389,6 +393,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
protocol: ModelBridgeProtocol
model: string
supportsImageInput: boolean
workMode: 'ask' | 'execute'
opencodeEntrypoint: string
environment?: Readonly<NodeJS.ProcessEnv>
spawn?: ModelBridgeHelperSpawn
@@ -409,7 +414,8 @@ export async function runOpenCodeModelBridgeHelper(options: {
protocol: options.protocol,
model: options.model,
loopbackOrigin: origin,
supportsImageInput: options.supportsImageInput
supportsImageInput: options.supportsImageInput,
workMode: options.workMode
})
const environment = credentialFreeHelperEnvironment(
options.environment ?? process.env,
@@ -487,6 +493,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
loopbackOrigin: string
name?: string
supportsImageInput?: boolean
workMode?: 'ask' | 'execute'
}): OpenCodeModelBridgeProviderConfig {
const model = boundedMetadataText(input.model, 'Model name')
const name = boundedMetadataText(
@@ -500,13 +507,25 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
const baseURL = `${origin}/v1`
return {
model: openCodeModelBridgeModelId(input.protocol, model),
...(input.workMode === 'ask'
? {
permission: 'ask' as const
}
: {}),
// GoodBuddy owns conversation titles. A first-prompt title request can
// race the real ACP prompt, abandon its Provider response, and correctly
// poison the no-replay ledger before the user request can run.
agent: {
title: {
disable: true
}
},
...(input.workMode === 'ask'
? {
build: {
permission: 'ask' as const
}
}
: {})
},
provider: {
[providerId]: {
+19 -1
View File
@@ -465,14 +465,19 @@ describe('model bridge loopback helper', () => {
name: 'Private model',
loopbackOrigin:
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
supportsImageInput: true
supportsImageInput: true,
workMode: 'ask'
})
expect(config).toMatchObject({
model: `${providerId}/private-model`,
permission: 'ask',
agent: {
title: {
disable: true
},
build: {
permission: 'ask'
}
},
provider: {
@@ -500,6 +505,19 @@ describe('model bridge loopback helper', () => {
expect(JSON.stringify(config)).not.toContain('provider.example')
}
)
it('leaves Execute provider permissions unchanged', () => {
const config = createOpenCodeModelBridgeProviderConfig({
protocol: 'openai-responses',
model: 'private-model',
loopbackOrigin:
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
workMode: 'execute'
})
expect(config).not.toHaveProperty('permission')
expect(config.agent).not.toHaveProperty('build')
})
})
describe('model bridge Unix broker', () => {
@@ -42,6 +42,16 @@ describe('OpenCode direct launch profile', () => {
process.env.HOME ?? fixture.workspaceDirectory,
PATH: process.env.PATH || '/usr/bin:/bin'
})
expect(
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
).toMatchObject({
permission: 'ask',
agent: {
build: {
permission: 'ask'
}
}
})
})
it('runs Execute directly with the SSH account environment', () => {
@@ -199,6 +209,8 @@ describe('OpenCode direct launch profile', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
resolve(fixture.bundleDirectory, 'bin', 'opencode')
])
+19 -1
View File
@@ -19,6 +19,15 @@ import {
assertAbsoluteManagedPath
} from './managed-paths'
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
permission: 'ask',
agent: {
build: {
permission: 'ask'
}
}
})
export type OpenCodeLaunchProfile = {
executable: string
processExecutable: string
@@ -143,16 +152,25 @@ export function createOpenCodeLaunchProfile(input: {
modelBridge.policy.model,
'--supports-image-input',
modelBridge.policy.supportsImageInput ? 'true' : 'false',
'--work-mode',
input.workMode,
'--opencode-entrypoint',
executablePath
]
}
const environment =
input.workMode === 'ask'
? {
...executeEnvironment(process.env, workspaceDirectory),
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
}
: executeEnvironment(process.env, workspaceDirectory)
return {
...runtimeCommand,
processExecutable: runtimeCommand.executable,
cwd: workspaceDirectory,
env: executeEnvironment(process.env, workspaceDirectory),
env: environment,
workMode: input.workMode
}
}
@@ -45,8 +45,10 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
mainAckSequence = '0'
failAppend = false
capacityBlocked = false
appendAttempts = 0
appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } {
this.appendAttempts += 1
if (this.capacityBlocked) {
throw new EventJournalCapacityError(
'journal capacity',
@@ -1251,6 +1253,34 @@ describe('RuntimeAcpBackend', () => {
expect(fixture.outputFrames).toEqual(['later'])
})
it('backs off while durable journal capacity remains unavailable', async () => {
vi.useFakeTimers()
try {
const fixture = harness()
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
fixture.journal.capacityBlocked = true
const output = fixture.process.emit('stdout', 'later')
await vi.advanceTimersByTimeAsync(775)
expect(fixture.journal.appendAttempts).toBeLessThanOrEqual(6)
expect(fixture.process.stops).toEqual([])
fixture.journal.capacityBlocked = false
await vi.advanceTimersByTimeAsync(800)
await output
expect(fixture.outputFrames).toEqual(['later'])
await fixture.backend.dispose()
} finally {
vi.useRealTimers()
}
})
it('stops with output quota when the detached durable journal is exhausted', async () => {
const fixture = harness()
await open(fixture)
+8 -2
View File
@@ -83,7 +83,8 @@ const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
const ACP_JOURNAL_RETRY_MILLISECONDS = 25
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
export type RuntimeAcpBackendErrorCode =
| 'capacity'
@@ -1976,6 +1977,7 @@ export class RuntimeAcpBackend {
try {
await this.#enqueueOutput(binding, payload, async () => {
const sequence = binding.nextOutputSequence.toString()
let retryDelay = ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS
while (true) {
try {
this.#options.journal.appendAcpFrame({
@@ -1998,7 +2000,11 @@ export class RuntimeAcpBackend {
)
}
this.#assertBeforeDeadline(binding)
await delay(ACP_JOURNAL_RETRY_MILLISECONDS)
await delay(retryDelay)
retryDelay = Math.min(
ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS,
retryDelay * 2
)
}
}
binding.nextOutputSequence += 1n
+12
View File
@@ -2279,7 +2279,19 @@ describe('AcpRemoteRuntime', () => {
done: false
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
const events = []
const firstUpdate = await stream.next()
expect(firstUpdate).toMatchObject({
value: { type: 'text' },
done: false
})
if (firstUpdate.done) {
throw new Error('Expected the first queued Runtime update')
}
events.push(firstUpdate.value)
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
while (true) {
const next = await stream.next()
if (next.done) {
+25 -17
View File
@@ -332,9 +332,9 @@ function usageEvent(
* Generic ACP runtime over an authenticated remote virtual channel.
*
* The class deliberately does not claim confinement from ACP itself. Ask
* permits only one-shot native reads, while the injected channel's read-only
* sandbox remains the filesystem boundary. Execute follows the user's full
* authorization for the selected SSH account.
* permits only one-shot native reads in addition to the OpenCode Ask
* permission profile. Execute follows the user's full authorization for the
* selected SSH account.
*/
export class AcpRemoteRuntime implements AgentRuntime {
readonly requiresToolApproval = false
@@ -1833,15 +1833,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
return
}
const bytes = utf8JsonBytes(notification.update)
const maximumItems = Math.max(
1,
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
)
const maximumBytes = Math.max(
1,
this.options.maxPendingUpdateBytes ??
DEFAULT_MAX_PENDING_UPDATE_BYTES
)
let consume: (() => void) | undefined
const consumed = new Promise<void>((resolve) => {
consume = resolve
@@ -1856,10 +1847,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt.wake = undefined
if (
!prompt.inboundPaused &&
(
prompt.updates.length >= maximumItems ||
prompt.pendingUpdateBytes >= maximumBytes
)
this.pendingUpdateLimitReached(prompt)
) {
prompt.inboundPaused = true
await this.awaitOperation(
@@ -1881,7 +1869,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt.pendingUpdateBytes - queued.bytes
)
queued.consumed()
if (prompt.inboundPaused && !prompt.interruption) {
if (
prompt.inboundPaused &&
!prompt.interruption &&
!this.pendingUpdateLimitReached(prompt)
) {
prompt.inboundPaused = false
void this.awaitOperation(
prompt.context,
@@ -1892,6 +1884,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
return queued.update
}
private pendingUpdateLimitReached(prompt: ActivePrompt): boolean {
const maximumItems = Math.max(
1,
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
)
const maximumBytes = Math.max(
1,
this.options.maxPendingUpdateBytes ??
DEFAULT_MAX_PENDING_UPDATE_BYTES
)
return (
prompt.updates.length >= maximumItems ||
prompt.pendingUpdateBytes >= maximumBytes
)
}
private discardUpdates(prompt: ActivePrompt): void {
for (const queued of prompt.updates.splice(0)) {
queued.consumed()
@@ -1346,6 +1346,88 @@ describe('ContinueHostAdapter', () => {
}
)
it('stops forwarding later deltas after the patched stream overflows', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests < 4
? []
: [
{
message: {
role: 'assistant',
content: 'prefix missing suffix'
}
}
]
},
isProcessing: stateRequests < 4,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests === 2
? [{ type: 'text', delta: 'prefix ' }]
: stateRequests === 3
? [{ type: 'text', delta: 'suffix' }]
: [],
goodbuddyEventsOverflow: stateRequests === 3
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
})
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).resolves.toMatchObject({
text: 'prefix missing suffix',
streamedText: true,
streamTruncated: true
})
expect(forwarded).toEqual([
{ type: 'text', delta: 'prefix ' }
])
})
it('forwards more than 100 unique tool calls', async () => {
const distribution = await createDistribution()
let stateRequests = 0
+2 -1
View File
@@ -1647,8 +1647,9 @@ export class ContinueHostAdapter {
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
if (
streamTruncated ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
this.dependencies.maximumStreamEventBytes
) {
streamTruncated = true
streamEvents = []
+4 -2
View File
@@ -750,8 +750,10 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
if (!result.streamedText || result.streamTruncated) {
const remainingText =
result.streamTruncated && result.text.startsWith(emittedText)
? result.text.slice(emittedText.length)
result.streamTruncated
? result.text.startsWith(emittedText)
? result.text.slice(emittedText.length)
: ''
: result.text
if (remainingText) {
yield {
+75
View File
@@ -160,6 +160,81 @@ describe('createAgentRuntime model compatibility', () => {
await runtime.dispose()
})
it('forwards the selected profile request customization to direct chats', async () => {
const profile = {
id: '00000000-0000-4000-8000-000000000041',
name: 'Tenant model',
baseUrl: 'https://gateway.example/v1',
modelName: 'tenant-model',
protocol: 'openai-chat-completions' as const,
authentication: 'none' as const,
supportsImageInput: false,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto' as const,
requestHeaders: {
'x-tenant-id': 'tenant-a'
},
requestBody: {
temperature: 0.2
}
}
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(
[
`data: ${JSON.stringify({
choices: [
{
delta: { content: 'OK' },
finish_reason: 'stop'
}
]
})}`,
'',
'data: [DONE]',
'',
''
].join('\n'),
{
status: 200,
headers: { 'content-type': 'text/event-stream' }
}
)
)
vi.stubGlobal('fetch', fetcher)
const runtime = createAgentRuntime(
process.cwd(),
settings({
modelProfiles: [profile],
defaultModelProfileId: profile.id
})
)
try {
for await (const _event of runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ee',
conversationId: 'customized-direct-chat',
prompt: 'hello'
},
new AbortController().signal
)) {
void _event
}
const init = fetcher.mock.calls[0]?.[1]
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
'tenant-a'
)
expect(JSON.parse(String(init?.body))).toMatchObject({
model: 'tenant-model',
temperature: 0.2
})
} finally {
await runtime.dispose()
vi.unstubAllGlobals()
}
})
it('forwards the selected profile image capability to direct runtimes', async () => {
const visionSettings = settings({
supportsImageInput: true
+3
View File
@@ -353,10 +353,13 @@ export function createAgentRuntime(
settings?.modelProtocol ??
defaultRuntimeSettings.modelProtocol,
authentication: modelAuthentication,
requestHeaders: defaultModelProfile?.requestHeaders,
requestBody: defaultModelProfile?.requestBody,
supportsImageInput:
defaultModelProfile?.supportsImageInput ??
settings?.supportsImageInput ??
defaultRuntimeSettings.supportsImageInput,
maximumOutputTokens: defaultModelProfile?.maximumOutputTokens,
imageGenerationQuality:
defaultModelProfile?.imageGenerationQuality ??
settings?.imageGenerationQuality ??
@@ -1,649 +0,0 @@
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type {
ModelCallIdentity,
PrepareModelCall
} from '../../shared/model-call-operation-contracts'
import {
ModelCallCapacityError,
ModelCallConflictError,
ModelCallOperationStore,
ModelCallStateError
} from './model-call-operation-store'
const directories: string[] = []
const digestA = `sha256:${'a'.repeat(64)}`
const digestB = `sha256:${'b'.repeat(64)}`
const profileDigest = `sha256:${'c'.repeat(64)}`
const policy = {
modelProfileDigest: profileDigest
}
const identity: ModelCallIdentity = {
callOperationId: 'call-1',
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
provider: 'openai',
profile: 'work',
model: 'gpt-model',
protocol: 'responses-v1'
}
const prepared: PrepareModelCall = {
identity,
requestDigest: digestA,
...policy
}
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-'))
directories.push(directory)
return join(directory, 'model-calls.sqlite')
}
function operation(callOperationId: string, roundIndex: number): PrepareModelCall {
return {
identity: {
...identity,
callOperationId,
promptOperationId: `prompt-${callOperationId}`,
roundIndex: 0
},
requestDigest: roundIndex % 2 === 0 ? digestA : digestB,
...policy
}
}
function completeCall(
store: ModelCallOperationStore,
callOperationId: string
): void {
store.beginDispatch(callOperationId)
store.complete(callOperationId, { status: 'completed' })
store.markResponseDelivered(callOperationId)
}
describe('ModelCallOperationStore', () => {
it('durably preserves prepared and dispatched crash states in WAL mode', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.prepare({
identity: {
...identity,
callOperationId: 'call-2',
promptOperationId: 'prompt-2',
roundIndex: 0
},
requestDigest: digestB,
...policy
})
expect(store.beginDispatch('call-2').permitted).toBe(true)
store.close()
const check = new DatabaseSync(path)
expect(
(check.prepare('PRAGMA journal_mode').get() as { journal_mode: string })
.journal_mode
).toBe('wal')
check.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.get('call-1')?.status).toBe('prepared')
expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined()
expect(reopened.get('call-2')?.status).toBe('dispatched')
expect(reopened.beginDispatch('call-2').permitted).toBe(false)
expect(reopened.listUnresolved().map((record) => record.status)).toEqual([
'prepared',
'dispatched'
])
reopened.close()
})
it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => {
const store = new ModelCallOperationStore(await createPath())
expect(store.prepare(prepared).created).toBe(true)
expect(store.prepare(prepared).created).toBe(false)
expect(() =>
store.prepare({ ...prepared, requestDigest: digestB })
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: { ...identity, model: 'different-model' }
})
).toThrow(ModelCallConflictError)
store.close()
})
it('grants only one dispatch permit across concurrent store instances', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
const claims = await Promise.all([
Promise.resolve().then(() => first.beginDispatch('call-1')),
Promise.resolve().then(() => second.beginDispatch('call-1'))
])
expect(claims.filter((claim) => claim.permitted)).toHaveLength(1)
expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe(
true
)
first.close()
second.close()
})
it('blocks replay after an outcome becomes unknown, including after reopen', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.beginDispatch('call-1', {
providerIdempotencyKey: 'provider-key-1'
})
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
providerRequestId: 'provider-request-1',
reason: {
code: 'connection-lost',
retryable: false
}
})
expect(store.beginDispatch('call-1').permitted).toBe(false)
store.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.beginDispatch('call-1').permitted).toBe(false)
expect(reopened.listUnresolved()).toHaveLength(1)
expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown')
reopened.close()
})
it('makes identical completion idempotent and rejects changed evidence', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
const evidence = {
status: 'completed' as const,
providerRequestId: 'request-provider-1',
providerResponseId: 'response-provider-1',
result: { finishReason: 'stop', outputDigest: digestB },
usage: { inputTokens: 10, outputTokens: 5 }
}
const completed = store.complete('call-1', evidence)
expect(store.complete('call-1', evidence)).toEqual(completed)
expect(() =>
store.complete('call-1', {
...evidence,
providerResponseId: 'different-response'
})
).toThrow(ModelCallConflictError)
expect(store.listUnresolved()).toEqual([])
store.close()
})
it('strictly rejects invalid transitions and different terminal outcomes', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
store.beginDispatch('call-1')
store.failDefinitive('call-1', {
status: 'failed-definitive',
error: {
code: 'authentication',
retryable: false
}
})
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
expect(() =>
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
reason: {
code: 'uncertain',
retryable: false
}
})
).toThrow(ModelCallStateError)
store.close()
})
it('guards closed stores and makes close and dispose idempotent', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
store.close()
expect(() => store.get('call-1')).toThrow(ModelCallStateError)
expect(() => store.prepare(prepared)).toThrow(ModelCallStateError)
expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError)
expect(() => store.listUnresolved()).toThrow(ModelCallStateError)
expect(() => store.close()).not.toThrow()
expect(() => store.dispose()).not.toThrow()
})
it('fails safely when persisted JSON is corrupt', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.close()
const corrupt = new DatabaseSync(path)
corrupt.exec(`
PRAGMA ignore_check_constraints = ON;
UPDATE model_call_operations SET identity_json = '{broken';
`)
corrupt.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.get('call-1')).toThrow(ModelCallStateError)
reopened.close()
})
it('enforces the hard record cap without dispatching prepared calls', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.prepare(operation('call-2', 1))
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('prepared')
expect(store.get('call-2')?.status).toBe('prepared')
expect(store.get('call-3')).toBeUndefined()
store.close()
})
it('automatically prunes only definitive terminal rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 3
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.failDefinitive('call-2', {
status: 'failed-definitive',
error: { code: 'provider-rejected', retryable: false }
})
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.prepare(operation('call-4', 3))
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('failed-definitive')
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
store.prepare(operation('call-5', 4))
expect(store.get('call-2')).toBeUndefined()
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
expect(store.get('call-5')?.status).toBe('prepared')
store.close()
})
it('never prunes dispatched or outcome-unknown rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.beginDispatch('call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.markOutcomeUnknown('call-2', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('dispatched')
expect(store.get('call-2')?.status).toBe('outcome-unknown')
store.close()
})
it('reapplies capacity and terminal pruning after reopen', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path, { maximumRecords: 3 })
for (let index = 1; index <= 3; index += 1) {
first.prepare(operation(`call-${index}`, index))
}
completeCall(first, 'call-1')
completeCall(first, 'call-2')
first.close()
const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 })
reopened.prepare(operation('call-4', 4))
expect(reopened.get('call-1')).toBeUndefined()
expect(reopened.get('call-2')).toBeUndefined()
expect(reopened.get('call-3')?.status).toBe('prepared')
expect(reopened.get('call-4')?.status).toBe('prepared')
reopened.close()
})
it('prunes terminal records deterministically with bounded options', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
completeCall(store, 'call-2')
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.markOutcomeUnknown('call-3', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(store.pruneTerminal(1, 6)).toBe(1)
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('completed')
expect(store.get('call-3')?.status).toBe('outcome-unknown')
expect(() => store.pruneTerminal(1_001)).toThrow()
store.close()
})
it('atomically grants one strictly monotonic next round across stores', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
const next = {
identity: {
...identity,
callOperationId: 'call-round-1-a',
roundIndex: 1
},
requestDigest: digestB,
...policy
}
const competing = {
...next,
identity: {
...next.identity,
callOperationId: 'call-round-1-b'
}
}
const results = await Promise.allSettled([
Promise.resolve().then(() => first.prepare(next)),
Promise.resolve().then(() => second.prepare(competing))
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
first.close()
second.close()
})
it('retains replay authority after delivered records are pruned and reopened', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
first.finalizePrompt({
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0
})
expect(first.pruneTerminal(1)).toBe(1)
expect(first.get('call-1')).toBeUndefined()
first.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError)
expect(
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-next-prompt',
requestId: 'request-2',
promptOperationId: 'prompt-2',
promptSequence: 1,
roundIndex: 0
},
requestDigest: digestB
}).created
).toBe(true)
expect(() =>
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-skipped-prompt',
requestId: 'request-3',
promptOperationId: 'prompt-3',
promptSequence: 3,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
reopened.close()
})
it('compacts finalized prompt authorities below the hard authority cap', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumAuthorities: 1
})
for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) {
const callOperationId = `call-prompt-${promptSequence}`
const promptOperationId = `prompt-${promptSequence}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${promptSequence}`,
promptOperationId,
promptSequence,
roundIndex: 0
}
})
completeCall(store, callOperationId)
store.finalizePrompt({
bindingId: identity.bindingId,
promptOperationId,
promptSequence
})
}
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'replayed-finalized-prompt',
promptOperationId: 'prompt-1',
promptSequence: 1,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('rejects skipped, lower, and profile-swapped rounds', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
completeCall(store, 'call-1')
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-skipped',
roundIndex: 2
}
})
).toThrow(ModelCallStateError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-profile-swap',
roundIndex: 1,
profile: 'other'
},
modelProfileDigest: digestB
})
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-lower',
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('permits sequential rounds without a call-count or cumulative-token quota', async () => {
const store = new ModelCallOperationStore(await createPath())
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const callOperationId = `call-unbounded-${roundIndex}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${roundIndex}`,
roundIndex
}
})
completeCall(store, callOperationId)
}
expect(store.get('call-unbounded-11')).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
store.close()
})
it('requires explicit response delivery before the next round', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
store.complete('call-1', { status: 'completed' })
const next = {
...prepared,
identity: {
...identity,
callOperationId: 'call-2',
roundIndex: 1
},
requestDigest: digestB
}
expect(() => store.prepare(next)).toThrow(ModelCallStateError)
expect(store.pruneTerminal(1)).toBe(0)
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
const delivered = store.markResponseDelivered('call-1')
expect(delivered.responseDeliveredAt).toBe(42)
expect(store.markResponseDelivered('call-1')).toEqual(delivered)
expect(store.prepare(next).created).toBe(true)
store.close()
})
it('paginates startup and status-filtered records without starvation', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
for (const id of ['call-a', 'call-b', 'call-c']) {
store.prepare(operation(id, 0))
store.beginDispatch(id)
}
store.complete('call-b', { status: 'completed' })
completeCall(store, 'call-c')
const first = store.listStartupRecords({ limit: 1 })
expect(first.records).toHaveLength(1)
expect(first.nextCursor).toBeDefined()
const second = store.listStartupRecords({
limit: 1,
cursor: first.nextCursor
})
expect([
...first.records,
...second.records
].map((record) => record.identity.callOperationId)).toEqual([
'call-a',
'call-b'
])
expect(
store.list({
statuses: ['completed'],
delivery: 'pending-response',
limit: 10
}).records.map((record) => record.identity.callOperationId)
).toEqual(['call-b'])
store.close()
})
it('includes prepared records in startup reconciliation', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(operation('call-prepared', 0))
expect(store.listStartupRecords().records).toMatchObject([
{
identity: { callOperationId: 'call-prepared' },
status: 'prepared'
}
])
store.close()
})
it('uses the verified private SQLite path opener before initialization', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.close()
const databaseStats = await stat(path)
expect(databaseStats.isFile()).toBe(true)
if (process.platform !== 'win32') {
expect(databaseStats.mode & 0o777).toBe(0o600)
}
expect(() => new ModelCallOperationStore(':memory:')).toThrow(
'SQLite database must be a filesystem path'
)
const invalidPath = join(
join(path, '..'),
'invalid-capacity-must-not-exist.sqlite'
)
expect(
() =>
new ModelCallOperationStore(invalidPath, {
maximumRecords: 0
})
).toThrow()
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
File diff suppressed because it is too large Load Diff
+40 -3
View File
@@ -1563,8 +1563,7 @@ describe('ModelAgentRuntime', () => {
)
})
it('cancels immediately during model retry backoff', async () => {
const controller = new AbortController()
it('does not retry a reset whose dispatch outcome is unknown', async () => {
const fetcher = vi.fn<typeof fetch>(async () => {
throw Object.assign(new Error('socket disconnected'), {
code: 'ECONNRESET'
@@ -1578,6 +1577,44 @@ describe('ModelAgentRuntime', () => {
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
await expect(consume()).rejects.toThrow('socket disconnected')
expect(fetcher).toHaveBeenCalledOnce()
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
})
)
})
it('cancels immediately during model retry backoff', async () => {
const controller = new AbortController()
const fetcher = vi.fn<typeof fetch>(async () => {
throw Object.assign(new Error('connect timeout'), {
code: 'UND_ERR_CONNECT_TIMEOUT'
})
})
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
@@ -1691,7 +1728,7 @@ describe('ModelAgentRuntime', () => {
await vi.runAllTimersAsync()
await assertion
expect(fetcher).toHaveBeenCalledTimes(4)
expect(fetcher).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
+5 -13
View File
@@ -195,22 +195,14 @@ const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryableModelNetworkErrorCodes = new Set([
const retryablePreDispatchNetworkErrorCodes = new Set([
'EAI_AGAIN',
'ECONNABORTED',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTDOWN',
'EHOSTUNREACH',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_SOCKET'
'UND_ERR_CONNECT_TIMEOUT'
])
const noModelTools: ModelToolProviderLike = {
@@ -617,7 +609,7 @@ function isRetryableModelRequestError(error: unknown): boolean {
const record = current as Record<string, unknown>
if (
typeof record.code === 'string' &&
retryableModelNetworkErrorCodes.has(
retryablePreDispatchNetworkErrorCodes.has(
record.code.toLocaleUpperCase()
)
) {
@@ -625,8 +617,8 @@ function isRetryableModelRequestError(error: unknown): boolean {
}
if (typeof record.message === 'string') {
if (
record.message === '模型接口请求超时' ||
/(?:fetch failed|failed to fetch|network(?: request)? (?:error|failed)|socket disconnected|connection (?:closed|reset)|\bECONNRESET\b|\bETIMEDOUT\b)/iu.test(
record.code === 'ECONNRESET' &&
/before secure TLS connection was established/iu.test(
record.message
)
) {
+137 -10
View File
@@ -86,6 +86,7 @@ function dependencies(
deps: Partial<OpenCodeRuntimeDependencies>
spawnMock: ReturnType<typeof vi.fn>
detectBinary: ReturnType<typeof vi.fn>
validateBinary: ReturnType<typeof vi.fn>
createClient: ReturnType<typeof vi.fn>
checkServerHealth: ReturnType<typeof vi.fn>
} {
@@ -94,12 +95,17 @@ function dependencies(
path: 'opencode',
detail: 'OpenCode CLI 已就绪'
})
const validateBinary = vi.fn().mockResolvedValue({
path: 'opencode',
detail: 'OpenCode CLI 已就绪'
})
const createClient = vi.fn(() => fakeClient())
const checkServerHealth = vi.fn().mockResolvedValue(true)
return {
deps: {
spawn: spawnMock as unknown as typeof spawn,
detectBinary,
validateBinary,
createClient: createClient as unknown as typeof createOpencodeClient,
checkServerHealth,
platform: 'linux',
@@ -108,6 +114,7 @@ function dependencies(
},
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
}
@@ -290,6 +297,126 @@ async function collectRun(
}
describe('OpenCodeRuntime embedded launcher', () => {
it('checks embedded availability without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
available: true,
detail: 'OpenCode 已配置,将在首次使用时启动'
})
expect(detectBinary).toHaveBeenCalledWith(
'opencode',
'',
undefined
)
expect(validateBinary).toHaveBeenCalledWith(
'opencode',
'opencode'
)
expect(spawnMock).not.toHaveBeenCalled()
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports an unavailable embedded binary without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
detectBinary.mockResolvedValueOnce({
path: '/missing/opencode',
detail: 'OpenCode CLI 已就绪'
})
validateBinary.mockResolvedValueOnce({
detail: 'OpenCode CLI 未找到'
})
const runtime = new OpenCodeRuntime(
options({
binaryPath: '/missing/opencode',
bundledBinaryPath: '/bundled/opencode'
}),
deps
)
await expect(runtime.getStatus()).resolves.toEqual({
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: true,
detail: 'OpenCode CLI 未找到'
})
expect(spawnMock).not.toHaveBeenCalled()
expect(detectBinary).toHaveBeenCalledWith(
'opencode',
'/missing/opencode',
'/bundled/opencode'
)
expect(validateBinary).toHaveBeenCalledWith(
'opencode',
'/missing/opencode'
)
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports a missing embedded model credential without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
const runtime = new OpenCodeRuntime(
options({
modelProfile: {
id: '00000000-0000-4000-8000-000000000011',
name: 'Independent model',
baseUrl: 'https://model.example',
modelName: 'private-model',
protocol: 'anthropic-messages',
authentication: 'api-key'
}
}),
deps
)
await expect(runtime.getStatus()).resolves.toEqual({
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: true,
detail: 'OpenCode 独立模型连接尚未配置 API Key'
})
expect(detectBinary).not.toHaveBeenCalled()
expect(validateBinary).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('uses the detected binary and passes an absolute config path only through env', async () => {
const serverChild = fakeChild(314)
const killerChild = fakeChild(315)
@@ -329,7 +456,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
}
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true,
detail:
'由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程'
@@ -476,7 +603,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -553,7 +680,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -626,7 +753,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
)
try {
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const environment = (
@@ -857,7 +984,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -931,7 +1058,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
'https://telemetry.invalid'
try {
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
@@ -996,7 +1123,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
.mockResolvedValueOnce(true)
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
expect(checkServerHealth).toHaveBeenCalledTimes(2)
@@ -1023,7 +1150,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
const runtime = new OpenCodeRuntime(options(), deps)
let settled = false
const statusPromise = runtime.getStatus().finally(() => {
const statusPromise = runtime.testConnection().finally(() => {
settled = true
})
@@ -1053,7 +1180,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
stderrOf(child).write(secret)
const runtime = new OpenCodeRuntime(options(), deps)
const status = await runtime.getStatus()
const status = await runtime.testConnection()
expect(status).toMatchObject({
available: false,
@@ -1088,7 +1215,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
const runtime = new OpenCodeRuntime(options(), deps)
const status = await runtime.getStatus()
const status = await runtime.testConnection()
expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)')
expect(status.detail).not.toContain(secret)
+76 -9
View File
@@ -477,6 +477,11 @@ export type OpenCodeRuntimeDependencies = {
configuredPath: string,
bundledPath?: string
) => Promise<{ path?: string; detail: string }>
validateBinary: (
runtime: 'opencode',
configuredPath: string,
bundledPath?: string
) => Promise<{ path?: string; detail: string }>
createClient: typeof createOpencodeClient
checkServerHealth: (
url: string,
@@ -695,6 +700,19 @@ async function defaultDetectBinary(
})
}
async function defaultValidateBinary(
runtime: 'opencode',
configuredPath: string
): Promise<{ path?: string; detail: string }> {
return detectRuntimeBinary({
binaryPath: configuredPath,
validation: 'canonical-file',
allowAutomaticDiscovery: false,
binaryNames: [runtime],
label: 'OpenCode CLI'
})
}
async function defaultCheckServerHealth(
url: string,
authorization: string,
@@ -740,6 +758,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.dependencies = {
spawn,
detectBinary: defaultDetectBinary,
validateBinary: defaultValidateBinary,
createClient: createOpencodeClient,
checkServerHealth: defaultCheckServerHealth,
platform: process.platform,
@@ -854,15 +873,37 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
if (signal?.aborted) {
throw new Error('OpenCode Server 启动已取消')
private async inspectEmbeddedConfiguration(
validatePath = false
): Promise<{
path?: string
detail: string
}> {
if (
this.options.modelProfile?.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
const detection = await this.dependencies.detectBinary(
'opencode',
this.options.binaryPath,
this.options.bundledBinaryPath
)
if (!validatePath || !detection.path) {
return detection
}
return this.dependencies.validateBinary(
'opencode',
detection.path
)
}
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
if (signal?.aborted) {
throw new Error('OpenCode Server 启动已取消')
}
const detection = await this.inspectEmbeddedConfiguration()
const binaryPath = detection.path
if (!binaryPath) {
throw new Error(detection.detail)
@@ -875,12 +916,6 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode Server 启动已取消')
}
if (
this.options.modelProfile?.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
const skillIds = this.getNativeSkillIds()
const registration = await this.createSkillRegistration()
try {
@@ -1186,6 +1221,38 @@ export class OpenCodeRuntime implements AgentRuntime {
}
async getStatus(): Promise<AgentRuntimeStatus> {
if (this.usesEmbeddedPermissionMediation() && !this.client) {
try {
const detection =
await this.inspectEmbeddedConfiguration(true)
return {
id: 'opencode',
label: 'OpenCode',
available: Boolean(detection.path),
supportsToolExecution: this.supportsToolExecution,
detail: detection.path
? 'OpenCode 已配置,将在首次使用时启动'
: detection.detail
}
} catch (error) {
return {
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail:
error instanceof Error ? error.message : 'OpenCode 不可用'
}
}
}
return this.checkConnection()
}
async testConnection(): Promise<AgentRuntimeStatus> {
return this.checkConnection()
}
private async checkConnection(): Promise<AgentRuntimeStatus> {
try {
const client = await this.getClient()
const response = await this.controlRequest(
-736
View File
@@ -1,736 +0,0 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createModelCallOperationId,
createResolvedModelProfileDigest,
RemoteModelGateway,
type RemoteModelGatewayDispatchContext
} from './remote-model-gateway'
const stores: ModelCallOperationStore[] = []
const storePaths = new Map<ModelCallOperationStore, string>()
const anthropicProfile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Anthropic',
baseUrl: 'https://provider.example',
modelName: 'claude-test',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKey: 'main-only-secret'
}
const context: RemoteModelGatewayDispatchContext = {
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile),
modelProfile: anthropicProfile
}
const request = {
method: 'POST',
path: '/v1/messages',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-test', messages: [] })
).toString('base64')
} as const
afterEach(() => {
vi.restoreAllMocks()
for (const store of stores.splice(0)) {
store.close()
}
})
async function createStore(): Promise<ModelCallOperationStore> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-'))
const path = join(directory, 'operations.sqlite')
const store = new ModelCallOperationStore(path)
stores.push(store)
storePaths.set(store, path)
return store
}
describe('RemoteModelGateway', () => {
it('prepares and claims before one authenticated provider fetch', async () => {
const store = await createStore()
const order: string[] = []
const prepare = vi.spyOn(store, 'prepare')
prepare.mockImplementation(function (input) {
order.push('prepare')
return ModelCallOperationStore.prototype.prepare.call(store, input)
})
const begin = vi.spyOn(store, 'beginDispatch')
begin.mockImplementation(function (id, metadata) {
order.push('begin')
return ModelCallOperationStore.prototype.beginDispatch.call(
store,
id,
metadata
)
})
const complete = vi.spyOn(store, 'complete')
let sentHeaders: Headers | undefined
let sentBody: Record<string, unknown> | undefined
const fetcher = vi.fn(async (url: URL, init?: RequestInit) => {
order.push('fetch')
expect(url.toString()).toBe(
'https://provider.example/v1/messages'
)
expect(init?.method).toBe('POST')
expect(init?.redirect).toBe('error')
sentHeaders = new Headers(init?.headers)
sentBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
id: 'message-1',
stop_reason: 'end_turn',
usage: {
input_tokens: 11,
output_tokens: 7,
cache_read_input_tokens: 3
}
}),
{
status: 200,
headers: {
'content-type': 'application/json',
'x-request-id': 'request-provider-1',
authorization: 'must-not-return'
}
}
)
})
const gateway = new RemoteModelGateway({ store, fetcher })
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(order).toEqual(['prepare', 'begin', 'fetch'])
expect(fetcher).toHaveBeenCalledOnce()
expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret')
expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01')
expect(sentBody).toEqual({
model: 'claude-test',
messages: []
})
expect(result.status).toBe(200)
expect(result.headers).toEqual({
'content-type': 'application/json',
'x-request-id': 'request-provider-1'
})
expect(JSON.stringify(result)).not.toContain('main-only-secret')
const completed = complete.mock.results[0]?.value
expect(completed?.status).toBe('completed')
expect(completed?.terminalEvidence).toMatchObject({
status: 'completed',
providerRequestId: 'request-provider-1',
providerResponseId: 'message-1',
result: {
finishReason: 'end_turn',
outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
},
usage: {
inputTokens: 11,
outputTokens: 7,
cachedInputTokens: 3
}
})
expect(JSON.stringify(completed)).not.toContain('main-only-secret')
expect(JSON.stringify(completed)).not.toContain('"messages"')
})
it('applies prompt-scoped custom headers and body fields before dispatch', async () => {
const store = await createStore()
const customizedProfile: ResolvedModelProfile = {
...anthropicProfile,
requestHeaders: {
'x-tenant-id': 'remote-tenant'
},
requestBody: {
temperature: 0.2,
metadata: { source: 'goodbuddy' }
}
}
const customizedRequest = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
temperature: 0.8
})
).toString('base64')
}
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
'remote-tenant'
)
expect(JSON.parse(String(init?.body))).toEqual({
messages: [],
metadata: { source: 'goodbuddy' },
model: 'claude-test',
temperature: 0.8
})
return new Response('{}')
})
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(
{
...context,
modelProfile: customizedProfile,
modelProfileDigest:
createResolvedModelProfileDigest(customizedProfile)
},
customizedRequest,
new AbortController().signal
)
expect(fetcher).toHaveBeenCalledOnce()
expect(
createResolvedModelProfileDigest(customizedProfile)
).not.toBe(createResolvedModelProfileDigest(anthropicProfile))
})
it('does not clamp Runtime-selected provider output parameters', async () => {
const store = await createStore()
let sentBody: Record<string, unknown> | undefined
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
sentBody = JSON.parse(String(init?.body)) as Record<
string,
unknown
>
return new Response('{}')
}
})
const requestWithProviderMaximum = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
max_tokens: 1_000_000
})
).toString('base64')
}
await gateway.dispatch(
context,
requestWithProviderMaximum,
new AbortController().signal
)
expect(sentBody).toMatchObject({ max_tokens: 1_000_000 })
})
it('does not persist endpoints, credentials, or provider bodies', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ private_response_field: 'secret' }))
})
await gateway.dispatch(context, request, new AbortController().signal)
store.close()
const databaseBytes = await readFile(storePaths.get(store)!)
const persisted = databaseBytes.toString('utf8')
expect(persisted).not.toContain('https://provider.example')
expect(persisted).not.toContain('main-only-secret')
expect(persisted).not.toContain('"messages"')
expect(persisted).not.toContain('private_response_field')
})
it('completes the ledger for a full HTTP 4xx response', async () => {
const store = await createStore()
const complete = vi.spyOn(store, 'complete')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ error: { type: 'invalid_request' } }), {
status: 400,
headers: { 'content-type': 'application/json' }
})
})
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(result.status).toBe(400)
expect(complete).toHaveBeenCalledOnce()
expect(complete.mock.results[0]?.value.status).toBe('completed')
})
it('permits only one fetch for duplicate canonical dispatches', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: 'already-dispatched'
})
expect(fetcher).toHaveBeenCalledOnce()
})
it('uses profile-independent round identity and rejects a profile swap', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const swappedProfile = {
...anthropicProfile,
id: '00000000-0000-4000-8000-000000000002',
modelName: 'claude-other'
}
await expect(
gateway.dispatch(
{
...context,
modelProfile: swappedProfile,
modelProfileDigest:
createResolvedModelProfileDigest(swappedProfile)
},
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-other', messages: [] })
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('validates the Main profile snapshot digest before ledger preparation', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
{ ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` },
request,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
})
it('fails closed on a different request for the same bound call', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [{ role: 'user', content: 'different' }]
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
for (const invalidRequest of [
{ ...request, path: 'https://attacker.example/v1/messages' },
{
...request,
headers: { authorization: 'Bearer remote-secret' }
},
{ ...request, path: '/v1/responses' },
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'untrusted-model' })
).toString('base64')
},
{ ...request, path: '/v1/responses' }
]) {
await expect(
gateway.dispatch(
context,
invalidRequest as typeof request,
new AbortController().signal
)
).rejects.toThrow()
}
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
expect(store.listUnresolved()).toEqual([])
})
it('enforces the trusted profile image-input policy before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
const imageRequest = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'AA=='
}
}
]
}
]
})
).toString('base64')
}
await expect(
gateway.dispatch(
context,
imageRequest,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
await expect(
gateway.dispatch(
{
...context,
modelProfile: {
...anthropicProfile,
supportsImageInput: true
},
modelProfileDigest: createResolvedModelProfileDigest({
...anthropicProfile,
supportsImageInput: true
})
},
imageRequest,
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{ background: true },
{ store: true },
{ service_tier: 'priority' },
{ web_search_options: {} },
{
tools: [
{
type: 'web_search_20250305',
name: 'web_search'
}
]
}
])(
'rejects independently billed provider capabilities before prepare',
async (providerCapability) => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
...providerCapability
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
}
)
it('allows ordinary client-defined tools within the token policy', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
tools: [
{
name: 'read_file',
description: 'Read a file',
input_schema: { type: 'object' }
}
]
})
).toString('base64')
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{
name: 'network failure',
expectedCode: 'network-error',
run: async () => {
throw new Error('socket exposed sensitive upstream details')
}
},
{
name: 'timeout',
expectedCode: 'timeout',
run: async (_url: URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
}),
timeoutMs: 5
}
])(
'marks $name after dispatch as outcome unknown',
async ({ expectedCode, run, timeoutMs }) => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: run,
...(timeoutMs ? { timeoutMs } : {})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: expectedCode,
message: expect.not.stringContaining('sensitive')
})
expect(store.listUnresolved()).toHaveLength(1)
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
status: 'outcome-unknown',
reason: { code: expectedCode, retryable: false }
}
})
}
)
it('marks caller cancellation after dispatch as outcome unknown', async () => {
const store = await createStore()
const controller = new AbortController()
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) =>
await new Promise<Response>((_resolve, reject) => {
controller.abort()
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
if (init?.signal?.aborted) {
reject(init.signal.reason)
}
})
})
await expect(
gateway.dispatch(context, request, controller.signal)
).rejects.toMatchObject({ code: 'cancelled' })
expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown')
})
it('marks an oversized response as outcome unknown', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(Buffer.alloc(768 * 1024 + 1), {
headers: {
'content-length': String(768 * 1024 + 1)
}
})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'response-too-large' })
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
reason: {
code: 'response-too-large',
retryable: false
}
}
})
})
it('keeps completion pending until delivery ACK and then permits the next round', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const nextContext = { ...context, roundIndex: 1 }
await expect(
gateway.dispatch(nextContext, request, new AbortController().signal)
).rejects.toThrow('not been completed and delivered')
expect(fetcher).toHaveBeenCalledOnce()
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
gateway.markResponseDelivered(context)
await gateway.dispatch(
nextContext,
request,
new AbortController().signal
)
expect(fetcher).toHaveBeenCalledTimes(2)
})
it('permits sequential responses without a cumulative output allowance', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () => new Response('{}')
})
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const round = { ...context, roundIndex }
await gateway.dispatch(
round,
request,
new AbortController().signal
)
gateway.markResponseDelivered(round)
}
expect(store.get(
createModelCallOperationId({ ...context, roundIndex: 11 })
)).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
})
it('marks schema-invalid bridge responses unknown before terminal completion', async () => {
const store = await createStore()
const response = new Response('{}')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
({
status: 999,
headers: response.headers,
body: response.body
}) as Response
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'invalid-bridge-response' })
expect(store.listStartupRecords().records).toMatchObject([
{
status: 'outcome-unknown',
terminalEvidence: {
reason: { code: 'invalid-bridge-response' }
}
}
])
})
it('injects OpenAI bearer auth without returning or persisting it', async () => {
const store = await createStore()
const profile: ResolvedModelProfile = {
...anthropicProfile,
baseUrl: 'https://openai.example/v1',
protocol: 'openai-responses'
}
let authorization: string | null = null
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
authorization = new Headers(init?.headers).get('authorization')
return new Response('{}')
}
})
const result = await gateway.dispatch(
{
...context,
modelProfile: profile,
modelProfileDigest: createResolvedModelProfileDigest(profile)
},
{ ...request, path: '/v1/responses' },
new AbortController().signal
)
expect(authorization).toBe('Bearer main-only-secret')
expect(JSON.stringify(result)).not.toContain('main-only-secret')
})
})
-652
View File
@@ -1,652 +0,0 @@
import { createHash } from 'node:crypto'
import {
modelCallIdentitySchema,
modelCallResultMetadataSchema,
modelCallUsageMetadataSchema,
type ModelCallResultMetadata,
type ModelCallUsageMetadata
} from '../../shared/model-call-operation-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../../shared/remote-model-gateway-contracts'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createOpenAIChatCompletionsUrl,
createOpenAIResponsesUrl
} from './openai-endpoint'
import {
BoundedResponseTooLargeError,
readBoundedResponseBytes
} from './bounded-response'
import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../../shared/model-request-policy'
import { modelBridgePathMatchesProtocol } from '../../shared/model-bridge-contracts'
import { canonicalJson } from '../../shared/agent-protocol/canonical'
import {
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders
} from '../../shared/model-request-customization'
const DEFAULT_TIMEOUT_MS = 60_000
const RESPONSE_HEADER_NAMES = [
'content-type',
'openai-request-id',
'request-id',
'x-request-id'
] as const
export type RemoteModelGatewayDispatchContext = {
requestId: string
bindingId: string
promptOperationId: string
promptSequence: number
roundIndex: number
modelProfileDigest: string
modelProfile: ResolvedModelProfile
}
export type RemoteModelGatewayOptions = {
store: ModelCallOperationStore
fetcher?: (
input: URL,
init: RequestInit
) => Promise<Response>
timeoutMs?: number
}
export class RemoteModelGatewayError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RemoteModelGatewayError'
this.code = code
}
}
/**
* Main-process-only, one-shot provider forwarder. Remote runtimes choose only
* a protocol-specific relative path and a bounded payload. Provider location,
* credentials, stable operation identity, and dispatch ownership stay in Main.
*/
export class RemoteModelGateway {
readonly #store: ModelCallOperationStore
readonly #fetcher: NonNullable<RemoteModelGatewayOptions['fetcher']>
readonly #timeoutMs: number
constructor(options: RemoteModelGatewayOptions) {
this.#store = options.store
this.#fetcher = options.fetcher ?? fetch
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 300_000
) {
throw new RangeError('Remote model gateway timeout is invalid')
}
this.#timeoutMs = timeoutMs
}
async dispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest,
signal: AbortSignal
): Promise<RemoteModelGatewayResponse> {
signal.throwIfAborted()
const prepared = prepareDispatch(context, request)
this.#store.prepare({
identity: prepared.identity,
requestDigest: prepared.requestDigest,
modelProfileDigest: prepared.modelProfileDigest
})
const claim = this.#store.beginDispatch(
prepared.identity.callOperationId
)
if (!claim.permitted) {
throw new RemoteModelGatewayError(
'already-dispatched',
'Remote model request was already dispatched'
)
}
const timeoutController = new AbortController()
const timeoutError = new RemoteModelGatewayError(
'timeout',
'Remote model request timed out'
)
const timer = setTimeout(
() => timeoutController.abort(timeoutError),
this.#timeoutMs
)
const requestSignal = AbortSignal.any([
signal,
timeoutController.signal
])
let response: Response
try {
response = await this.#fetcher(prepared.url, {
method: 'POST',
redirect: 'error',
headers: prepared.headers,
body: prepared.body,
signal: requestSignal
})
} catch {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: 'network-error'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
let responseBody: Uint8Array
try {
responseBody = await readBoundedResponseBytes(response, {
maxBytes:
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
tooLargeMessage: 'Remote model response is too large',
truncatedMessage: 'Remote model response was truncated'
})
} catch (error) {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'response-read-failed'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
} finally {
clearTimeout(timer)
}
if (timeoutController.signal.aborted || signal.aborted) {
const code = timeoutController.signal.aborted
? 'timeout'
: 'cancelled'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
const responseHeaders = selectResponseHeaders(response.headers)
const providerRequestId =
safeProviderIdentifier(
response.headers.get('x-request-id')
) ??
safeProviderIdentifier(
response.headers.get('openai-request-id')
) ??
safeProviderIdentifier(response.headers.get('request-id'))
let bridgeResponse: RemoteModelGatewayResponse
try {
bridgeResponse = remoteModelGatewayResponseSchema.parse({
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(responseBody).toString('base64')
})
} catch {
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
'invalid-bridge-response'
)
}
const parsedMetadata = parseResponseMetadata(responseBody)
this.#store.complete(prepared.identity.callOperationId, {
status: 'completed',
...(providerRequestId ? { providerRequestId } : {}),
...(parsedMetadata.providerResponseId
? { providerResponseId: parsedMetadata.providerResponseId }
: {}),
result: {
outputDigest: sha256(responseBody),
...(parsedMetadata.result ?? {})
},
...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {})
})
return bridgeResponse
}
markResponseDelivered(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): void {
this.#store.markResponseDelivered(
createModelCallOperationId(context)
)
}
finalizePrompt(context: {
bindingId: string
promptOperationId: string
promptSequence: number
}): void {
this.#store.finalizePrompt(context)
}
#markUnknownAndThrow(
callOperationId: string,
code: string
): never {
this.#store.markOutcomeUnknown(callOperationId, {
status: 'outcome-unknown',
reason: {
code,
retryable: false
}
})
throw new RemoteModelGatewayError(
code,
remoteModelGatewayErrorMessage(code)
)
}
}
function prepareDispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest
): {
identity: ReturnType<typeof modelCallIdentitySchema.parse>
requestDigest: string
modelProfileDigest: string
url: URL
headers: Record<string, string>
body: Uint8Array
} {
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
const profile = context.modelProfile
const modelProfileDigest = createResolvedModelProfileDigest(profile)
if (modelProfileDigest !== context.modelProfileDigest) {
throw new RemoteModelGatewayError(
'model-profile-digest-mismatch',
'Trusted model profile snapshot does not match its digest'
)
}
const url = providerUrl(profile, parsedRequest.path)
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password
) {
throw new RemoteModelGatewayError(
'invalid-provider-endpoint',
'Configured model provider endpoint is invalid'
)
}
if (profile.authentication === 'api-key' && !profile.apiKey) {
throw new RemoteModelGatewayError(
'missing-provider-credential',
'Configured model provider credential is unavailable'
)
}
const body = normalizeProviderRequestBody(
profile,
Buffer.from(parsedRequest.bodyBase64, 'base64')
)
if (
body.byteLength >
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
) {
throw new RemoteModelGatewayError(
'request-too-large',
'Customized remote model request is too large'
)
}
const providerHeaders = mergeModelRequestHeaders(
profile.requestHeaders ?? {},
parsedRequest.headers
)
if (profile.protocol === 'anthropic-messages') {
providerHeaders.set('anthropic-version', '2023-06-01')
}
const canonicalHeaders =
canonicalModelRequestHeaders(providerHeaders)
const normalizedBodyBase64 = body.toString('base64')
const canonicalRequest = JSON.stringify({
profileId: profile.id,
protocol: profile.protocol,
authentication: profile.authentication,
model: profile.modelName,
url: url.toString(),
request: {
method: parsedRequest.method,
path: parsedRequest.path,
headers: canonicalHeaders,
bodyBase64: normalizedBodyBase64
}
})
const requestDigest = sha256(canonicalRequest)
const callOperationId = createModelCallOperationId(context)
const identity = modelCallIdentitySchema.parse({
callOperationId,
requestId: context.requestId,
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
promptSequence: context.promptSequence,
roundIndex: context.roundIndex,
provider:
profile.protocol === 'anthropic-messages'
? 'anthropic'
: 'openai',
profile: profile.id,
model: profile.modelName,
protocol: profile.protocol
})
if (profile.protocol === 'anthropic-messages') {
if (profile.authentication === 'api-key') {
providerHeaders.set('x-api-key', profile.apiKey!)
}
} else if (profile.authentication === 'api-key') {
providerHeaders.set('authorization', `Bearer ${profile.apiKey!}`)
}
return {
identity,
requestDigest,
modelProfileDigest,
url,
headers: Object.fromEntries(providerHeaders.entries()),
body
}
}
export function createModelCallOperationId(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): string {
return `model-call-${createHash('sha256')
.update(
JSON.stringify({
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
roundIndex: context.roundIndex
})
)
.digest('hex')}`
}
export function createResolvedModelProfileDigest(
profile: ResolvedModelProfile
): string {
return sha256(
canonicalJson({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null,
requestHeaders: Object.fromEntries(
Object.entries(profile.requestHeaders ?? {}).map(
([name, value]) => [name.toLowerCase(), value]
)
),
requestBody: profile.requestBody ?? {}
})
)
}
function normalizeProviderRequestBody(
profile: ResolvedModelProfile,
body: Buffer
): Buffer {
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(body)
) as unknown
} catch {
throw requestPolicyError()
}
if (
profile.protocol !== 'anthropic-messages' &&
profile.protocol !== 'openai-chat-completions' &&
profile.protocol !== 'openai-responses'
) {
throw requestPolicyError()
}
const request = mergeModelRequestBody(
profile.requestBody ?? {},
value as Record<string, unknown>
)
try {
assertTextModelRequestPolicy(
{
protocol: profile.protocol,
model: profile.modelName,
supportsImageInput: profile.supportsImageInput === true
},
request
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
throw error
}
throw requestPolicyError()
}
return Buffer.from(canonicalJson(request), 'utf8')
}
function requestPolicyError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'request-policy-mismatch',
'Remote model request does not match the trusted model policy'
)
}
function providerUrl(
profile: ResolvedModelProfile,
path: string
): URL {
if (
profile.protocol === 'openai-images-generations' ||
!modelBridgePathMatchesProtocol(profile.protocol, path)
) {
throw protocolPathError()
}
if (profile.protocol === 'anthropic-messages') {
return createAnthropicMessagesUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-chat-completions') {
return createOpenAIChatCompletionsUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-responses') {
return createOpenAIResponsesUrl(profile.baseUrl)
}
throw protocolPathError()
}
function protocolPathError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'protocol-path-mismatch',
'Remote model request path does not match the configured protocol'
)
}
function selectResponseHeaders(
headers: Headers
): Record<string, string> {
const selected: Record<string, string> = {}
for (const name of RESPONSE_HEADER_NAMES) {
const value = headers.get(name)
const encodedBytes =
value === null ? 0 : new TextEncoder().encode(value).byteLength
if (
value !== null &&
encodedBytes > 0 &&
!/[\r\n]/u.test(value) &&
encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes
) {
selected[name] = value
}
}
return selected
}
function parseResponseMetadata(body: Uint8Array): {
providerResponseId?: string
result?: ModelCallResultMetadata
usage?: ModelCallUsageMetadata
} {
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
} catch {
return {}
}
if (!isRecord(parsed)) {
return {}
}
const providerResponseId = safeProviderIdentifier(parsed.id)
const finishReason = safeFinishReason(
isRecordArray(parsed.choices) &&
isRecord(parsed.choices[0])
? parsed.choices[0].finish_reason
: parsed.stop_reason
)
const resultCandidate = {
...(finishReason ? { finishReason } : {})
}
const result = modelCallResultMetadataSchema.safeParse(resultCandidate)
const usage = parseUsage(parsed.usage)
return {
...(providerResponseId ? { providerResponseId } : {}),
...(result.success && Object.keys(result.data).length > 0
? { result: result.data }
: {}),
...(usage ? { usage } : {})
}
}
function parseUsage(value: unknown): ModelCallUsageMetadata | undefined {
if (!isRecord(value)) {
return undefined
}
const promptDetails = isRecord(value.prompt_tokens_details)
? value.prompt_tokens_details
: undefined
const inputDetails = isRecord(value.input_tokens_details)
? value.input_tokens_details
: undefined
const completionDetails = isRecord(value.completion_tokens_details)
? value.completion_tokens_details
: undefined
const outputDetails = isRecord(value.output_tokens_details)
? value.output_tokens_details
: undefined
const candidate = {
inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens),
outputTokens: safeTokenCount(
value.output_tokens ?? value.completion_tokens
),
cachedInputTokens: safeTokenCount(
value.cache_read_input_tokens ??
inputDetails?.cached_tokens ??
promptDetails?.cached_tokens
),
reasoningTokens: safeTokenCount(
outputDetails?.reasoning_tokens ??
completionDetails?.reasoning_tokens
)
}
const compact = Object.fromEntries(
Object.entries(candidate).filter((entry) => entry[1] !== undefined)
)
const result = modelCallUsageMetadataSchema.safeParse(compact)
return result.success && Object.keys(result.data).length > 0
? result.data
: undefined
}
function safeTokenCount(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0
? value
: undefined
}
function safeProviderIdentifier(value: unknown): string | undefined {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 256 ||
value.trim() !== value ||
new TextEncoder().encode(value).byteLength > 256
) {
return undefined
}
return value
}
function safeFinishReason(value: unknown): string | undefined {
const result =
modelCallResultMetadataSchema.shape.finishReason.safeParse(value)
return result.success ? result.data : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
return Array.isArray(value) && value.every(isRecord)
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
function remoteModelGatewayErrorMessage(code: string): string {
switch (code) {
case 'timeout':
return 'Remote model request timed out after dispatch'
case 'cancelled':
return 'Remote model request was cancelled after dispatch'
case 'response-too-large':
return 'Remote model response exceeded the allowed size'
case 'response-read-failed':
return 'Remote model response could not be read'
case 'invalid-bridge-response':
return 'Remote model response could not be safely delivered'
default:
return 'Remote model request outcome is unknown'
}
}
@@ -288,6 +288,36 @@ describe('SelectedRuntimeManager', () => {
expect(cached.dispose).toHaveBeenCalledOnce()
})
it('uses a lightweight factory only for passive status probes', async () => {
const probed = runtime()
const tested = runtime()
const create = vi.fn(async () => tested.value)
const createStatus = vi.fn(async () => probed.value)
const manager = new SelectedRuntimeManager(
create,
undefined,
undefined,
undefined,
createStatus
)
const selection = { provider: 'opencode' as const }
await expect(manager.getStatus(selection)).resolves.toMatchObject({
available: true
})
expect(createStatus).toHaveBeenCalledWith(selection)
expect(create).not.toHaveBeenCalled()
expect(probed.dispose).toHaveBeenCalledOnce()
await expect(manager.testStatus(selection)).resolves.toMatchObject({
available: true
})
expect(create).toHaveBeenCalledWith(selection)
expect(tested.testConnection).toHaveBeenCalledOnce()
expect(tested.dispose).toHaveBeenCalledOnce()
await manager.dispose()
})
it('disposes a native-inventory runtime without caching it', async () => {
const inspected = runtime()
const cached = runtime()
+3 -2
View File
@@ -75,7 +75,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private readonly maximumRetainedRuntimes =
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
private readonly observeFailure?:
DesktopDiagnosticFailureObserver
DesktopDiagnosticFailureObserver,
private readonly createStatusRuntime = createRuntime
) {
if (
!Number.isSafeInteger(maximumCachedRuntimes) ||
@@ -339,7 +340,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private async runStatusProbe(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeStatus> {
const runtime = await this.createRuntime(selection)
const runtime = await this.createStatusRuntime(selection)
try {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
+97 -21
View File
@@ -7,6 +7,8 @@ import {
conversationMessageSchema,
conversationSnapshotSchema,
expertCreateSchema,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
@@ -260,6 +262,7 @@ type MessageMetadata = {
status?: string
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
blocks?: ConversationSnapshot['messages'][number]['blocks']
displayCaptureTruncated?: boolean
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
tools?: ConversationSnapshot['messages'][number]['tools']
@@ -1154,6 +1157,7 @@ function serializeConversationMessageMetadata(
status: message.status,
reasoning: message.reasoning,
blocks: message.blocks,
displayCaptureTruncated: message.displayCaptureTruncated,
contextCompression: message.contextCompression,
contextCompressions: message.contextCompressions,
tools: message.tools,
@@ -1188,6 +1192,9 @@ function appendRecoveredMessageBlock(
)
}
} else {
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: randomUUID(),
type,
@@ -1218,6 +1225,9 @@ function upsertRecoveredToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'tool' as const, tool }
@@ -1257,6 +1267,9 @@ function upsertRecoveredSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
@@ -1289,6 +1302,16 @@ function failedToolRepresentsError(
)
}
function canRetainRecoveredActivity(
message: ConversationMessage
): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function reduceRecoveredAgentEvent(
message: ConversationMessage,
event: RemoteConversationTaskEventInput['event']
@@ -1300,10 +1323,18 @@ function reduceRecoveredAgentEvent(
maximumRecoveredMessageLength - message.content.length
)
const delta = event.delta.slice(0, remaining)
const blocks = appendRecoveredMessageBlock(
message.blocks,
'text',
delta
)
next = {
...message,
content: `${message.content}${delta}`,
blocks: appendRecoveredMessageBlock(message.blocks, 'text', delta),
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
@@ -1315,14 +1346,18 @@ function reduceRecoveredAgentEvent(
0,
Math.max(0, maximumRecoveredMessageLength - reasoning.length)
)
const blocks = appendRecoveredMessageBlock(
message.blocks,
'reasoning',
delta
)
next = {
...message,
reasoning: `${reasoning}${delta}`,
blocks: appendRecoveredMessageBlock(
message.blocks,
'reasoning',
delta
),
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status: undefined
}
} else if (event.type === 'status') {
@@ -1370,13 +1405,25 @@ function reduceRecoveredAgentEvent(
)
if (index >= 0) {
tools[index] = tool
} else {
} else if (!canRetainRecoveredActivity(message)) {
next = {
...message,
displayCaptureTruncated: true
}
}
if (index < 0 && next === message) {
tools.push(tool)
}
next = {
...message,
tools,
blocks: upsertRecoveredToolBlock(message.blocks, tool)
if (next === message) {
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
}
} else if (event.type === 'subagent') {
const commonSubagent = {
@@ -1401,24 +1448,49 @@ function reduceRecoveredAgentEvent(
const index = subagents.findIndex(
(candidate) => candidate.childTaskId === event.childTaskId
)
let retainSubagent = true
if (index >= 0) {
subagents[index] = subagent
} else {
subagents.push(subagent)
}
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
: message.tools,
blocks: upsertRecoveredSubagentBlock(
)
if (
!replacesTool &&
!canRetainRecoveredActivity(message)
) {
retainSubagent = false
} else {
subagents.push(subagent)
}
}
if (!retainSubagent) {
next = {
...message,
displayCaptureTruncated: true
}
} else {
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
}
} else if (event.type === 'artifact') {
next = {
@@ -4600,6 +4672,8 @@ export class AssistantDatabase {
status: metadata.status,
reasoning: metadata.reasoning,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
tools: metadata.tools,
@@ -5002,6 +5076,8 @@ export class AssistantDatabase {
status: metadata.status,
reasoning: metadata.reasoning,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
tools: metadata.tools,
+22 -98
View File
@@ -152,10 +152,6 @@ import {
import {
RemoteEnvironmentOperationStore
} from './remote-agent/remote-environment-operation-store'
import type {
RuntimeSettings,
RuntimeSettingsInput
} from '../shared/contracts'
import {
DesktopDiagnostics,
type DesktopDiagnosticFailureObserver
@@ -364,84 +360,6 @@ async function createEmbeddingProvider(
}
}
function runtimeSettingsInputWithEmbeddingSelection(
settings: RuntimeSettings,
activeEmbeddingConnectionId: string
): RuntimeSettingsInput {
const configured = settings.configured ?? settings
const defaultProfile =
settings.modelProfiles.find(
(profile) => profile.id === settings.defaultModelProfileId
) ?? settings.modelProfiles[0]
if (!defaultProfile || !settings.embeddingConnections) {
throw new Error('模型连接设置不完整')
}
return {
provider: settings.provider,
modelBaseUrl: defaultProfile.baseUrl,
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
imageGenerationQuality: defaultProfile.imageGenerationQuality,
opencodeBaseUrl: configured.opencodeBaseUrl,
opencodeEmbedded: settings.opencodeEmbedded,
opencodeBinaryPath: configured.opencodeBinaryPath,
opencodeConfigPath: configured.opencodeConfigPath,
continueBinaryPath: configured.continueBinaryPath,
continueConfigPath: configured.continueConfigPath,
continueMode: settings.continueMode,
subagentSmartRoutingEnabled: settings.subagentSmartRoutingEnabled,
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: { action: 'keep' },
embeddingConnections: settings.embeddingConnections.map(
(connection) =>
connection.kind === 'builtin'
? {
id: connection.id,
name: connection.name,
kind: connection.kind
}
: {
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: connection.baseUrl,
modelName: connection.modelName,
authentication: connection.authentication,
apiKey: { action: 'keep' as const }
}
),
activeEmbeddingConnectionId,
knowledgeRerankEnabled: settings.knowledgeRerankEnabled ?? false,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint ?? '',
knowledgeRerankModel: settings.knowledgeRerankModel ?? '',
knowledgeRerankApiKey: { action: 'keep' },
contextCompression: settings.contextCompression,
workspacePath: configured.workspacePath,
apiKey: { action: 'keep' },
modelProfiles: settings.modelProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
})),
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource: configured.opencodeModelSource,
continueModelSource: configured.continueModelSource,
deepseekHarnessModelSource:
configured.deepseekHarnessModelSource ?? { kind: 'platform' },
toolApproval: settings.toolApproval
}
}
function createRerankProvider(
settings: ResolvedRuntimeSettings
): CohereRerankClient | undefined {
@@ -1120,6 +1038,22 @@ if (hasSingleInstanceLock) {
executionSpace
)
}
const createSelectedStatusRuntime = async (
selection: AgentRuntimeSelection
): Promise<AgentRuntime> => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
)
return resolved.target === 'opencode'
? createAgentRuntime(defaultWorkspace, resolved.settings, {
bundledRuntimePaths
})
: createRuntimeWithCapabilities(
resolved.settings,
resolved.target
)
}
const configuredRuntime = await runStartupPrerequisites({
prepareDeepSeekHome: async () => {
await mkdir(deepSeekHarnessHome, {
@@ -1192,7 +1126,8 @@ if (hasSingleInstanceLock) {
createSelectedRuntime,
undefined,
undefined,
observeDesktopFailure
observeDesktopFailure,
createSelectedStatusRuntime
)
const contextManager = new ContextManager({
parseDocument: documentParsingService.parse
@@ -1329,27 +1264,16 @@ if (hasSingleInstanceLock) {
const setCurrentEmbeddingConnection = async (
connectionId: string
): Promise<void> => {
const previous = await settingsStore.getPublicSettings()
if (previous.activeEmbeddingConnectionId === connectionId) {
const selection =
await settingsStore.setActiveEmbeddingConnection(connectionId)
if (!selection.changed) {
return
}
await settingsStore.update(
runtimeSettingsInputWithEmbeddingSelection(
previous,
connectionId
)
)
try {
await reconfigureRuntimes()
} catch (activationError) {
try {
await settingsStore.update(
runtimeSettingsInputWithEmbeddingSelection(
previous,
previous.activeEmbeddingConnectionId ??
connectionId
)
)
await selection.restore()
await reconfigureRuntimes()
} catch (rollbackError) {
throw new AggregateError(
+25 -8
View File
@@ -35,14 +35,31 @@ describe('packaged release notes', () => {
)
}
}
const releaseWithLongScenario = parsed.releases.find(
(release) => release.version === packageSource.version
)
expect(
releaseWithLongScenario?.notes['en-US'].features.some(
(item) => item.length > 500
)
).toBe(true)
expect(() =>
releaseNotesFileSchema.parse({
formatVersion: 1,
releases: [
{
version: '1.0.0',
releasedAt: '2026-01-01',
notes: {
'zh-CN': {
highlights: [],
features: ['详'.repeat(501)],
fixes: [],
notices: []
},
'en-US': {
highlights: [],
features: ['detail '.repeat(100)],
fixes: [],
notices: []
}
}
}
]
})
).not.toThrow()
const currentRelease = parsed.releases.find(
(release) => release.version === '0.10.1'
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { createManagedModelBridge } from './managed-model-bridge'
import {
createManagedModelBridge,
createResolvedModelProfileDigest
} from './managed-model-bridge'
const profile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
@@ -80,4 +83,22 @@ describe('managed Agent prompt model setup', () => {
})
).toThrow(/usable text model profile/iu)
})
it('binds request customization but not credentials into the profile digest', () => {
expect(
createResolvedModelProfileDigest({
...profile,
apiKey: 'different-secret'
})
).toBe(createResolvedModelProfileDigest(profile))
expect(
createResolvedModelProfileDigest({
...profile,
requestBody: {
...profile.requestBody,
temperature: 0.4
}
})
).not.toBe(createResolvedModelProfileDigest(profile))
})
})
+28 -1
View File
@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto'
import { canonicalJson } from '../../shared/agent-protocol/canonical'
import {
agentPromptModelProfileSchema,
modelBridgePolicySchema,
type AgentPromptModelProfile,
type ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
import { createResolvedModelProfileDigest } from '../agent/remote-model-gateway'
import type { ResolvedModelProfile } from '../runtime-settings-store'
const DEFAULT_MAXIMUM_OUTPUT_TOKENS = 32_000
@@ -19,6 +20,32 @@ export type ManagedModelBridge = {
profile: AgentPromptModelProfile
}
export function createResolvedModelProfileDigest(
profile: ResolvedModelProfile
): string {
const canonical = canonicalJson({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null,
requestHeaders: Object.fromEntries(
Object.entries(profile.requestHeaders ?? {}).map(
([name, value]) => [name.toLowerCase(), value]
)
),
requestBody: profile.requestBody ?? {}
})
return `sha256:${createHash('sha256')
.update(canonical)
.digest('hex')}`
}
export function createManagedModelBridge(options: {
profile: ResolvedModelProfile
}): ManagedModelBridge {
+166
View File
@@ -1301,6 +1301,172 @@ describe('RuntimeSettingsStore', () => {
).toBe(21)
})
it('changes the active embedding connection without changing model profiles', async () => {
const { store } = await createStore()
const profileId = '00000000-0000-4000-8000-000000000091'
const connectionId = '00000000-0000-4000-8000-000000000092'
await store.update(
settings({
modelProfiles: [
{
id: profileId,
name: 'Tenant model',
baseUrl: 'https://gateway.example/v1',
modelName: 'tenant-model',
protocol: 'anthropic-messages',
authentication: 'api-key',
supportsImageInput: false,
contextWindowTokens: 200_000,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto',
requestHeaders: { 'x-tenant-id': 'tenant-a' },
requestBody: { temperature: 0.2 },
apiKey: { action: 'replace', value: 'tenant-secret' }
}
],
defaultModelProfileId: profileId,
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: connectionId,
name: '团队向量模型',
kind: 'openai-compatible',
baseUrl: 'https://team-vectors.example/v1/embeddings',
modelName: 'team/embed-v2',
authentication: 'none',
apiKey: { action: 'keep' }
}
],
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
})
)
const selection =
await store.setActiveEmbeddingConnection(connectionId)
expect(selection).toMatchObject({
changed: true,
previousConnectionId: builtinEmbeddingConnectionId
})
await expect(store.getResolvedSettings()).resolves.toMatchObject({
activeEmbeddingConnectionId: connectionId,
modelProfiles: [
expect.objectContaining({
id: profileId,
contextWindowTokens: 200_000,
maximumOutputTokens: 48_000,
requestHeaders: { 'x-tenant-id': 'tenant-a' },
requestBody: { temperature: 0.2 },
apiKey: 'tenant-secret'
})
]
})
})
it('preserves configured model fields when embedding changes under an environment override', async () => {
const { filePath, store } = await createStore()
const profileId = '00000000-0000-4000-8000-000000000093'
await store.update(
settings({
modelProfiles: [
{
id: profileId,
name: 'Stored OpenAI model',
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
protocol: 'openai-chat-completions',
authentication: 'none',
supportsImageInput: false,
imageGenerationQuality: 'auto',
requestHeaders: { 'x-tenant-id': 'tenant-a' },
requestBody: { temperature: 0.2 },
apiKey: { action: 'keep' }
}
],
defaultModelProfileId: profileId
})
)
const environmentStore = new RuntimeSettingsStore(
filePath,
cipher,
{ GOODBUDDY_BIGTOKEN_API_KEY: 'environment-key' }
)
const effective = await environmentStore.getPublicSettings()
expect(effective.modelProfiles[0]).toMatchObject({
protocol: 'anthropic-messages',
authentication: 'api-key',
credentialSource: 'environment'
})
await environmentStore.setActiveEmbeddingConnection(
legacyEmbeddingConnectionId
)
const withoutEnvironment = new RuntimeSettingsStore(
filePath,
cipher,
{}
)
await expect(
withoutEnvironment.getResolvedSettings()
).resolves.toMatchObject({
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
modelProfiles: [
expect.objectContaining({
id: profileId,
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
protocol: 'openai-chat-completions',
authentication: 'none',
requestHeaders: { 'x-tenant-id': 'tenant-a' },
requestBody: { temperature: 0.2 }
})
]
})
})
it('serializes embedding selection with full settings updates', async () => {
const { store } = await createStore()
const settingsUpdate = store.update(
settings({ modelName: 'updated-after-selection-read' })
)
const selectionUpdate =
store.setActiveEmbeddingConnection(
legacyEmbeddingConnectionId
)
await Promise.all([settingsUpdate, selectionUpdate])
await expect(store.getResolvedSettings()).resolves.toMatchObject({
modelName: 'updated-after-selection-read',
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
})
})
it('rolls back only the embedding selection after later settings updates', async () => {
const { store } = await createStore()
const selection =
await store.setActiveEmbeddingConnection(
legacyEmbeddingConnectionId
)
await store.update(
settings({
modelName: 'saved-while-embedding-activated',
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
})
)
await selection.restore()
await expect(store.getResolvedSettings()).resolves.toMatchObject({
modelName: 'saved-while-embedding-activated',
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
})
})
it('migrates version 13 with reranking disabled by default', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
+84
View File
@@ -305,6 +305,11 @@ export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
restore(): Promise<RuntimeSettings>
}
export type ActiveEmbeddingConnectionUpdate = {
changed: boolean
previousConnectionId: string
restore(): Promise<RuntimeSettings>
}
type Version17StoredSettings = z.infer<
typeof version17StoredSettingsSchema
>
@@ -1998,6 +2003,85 @@ export class RuntimeSettingsStore {
return operation
}
setActiveEmbeddingConnection(
connectionId: string
): Promise<ActiveEmbeddingConnectionUpdate> {
let result: ActiveEmbeddingConnectionUpdate | undefined
const operation = this.updateQueue.then(async () => {
const current = await this.load()
const previousConnectionId =
current.activeEmbeddingConnectionId
const changed = previousConnectionId !== connectionId
if (changed) {
const next = this.withActiveEmbeddingConnection(
current,
connectionId
)
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
this.loadWarnings = []
}
result = {
changed,
previousConnectionId,
restore: () =>
this.restoreActiveEmbeddingConnection(
connectionId,
previousConnectionId
)
}
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation.then(() => result!)
}
private restoreActiveEmbeddingConnection(
expectedConnectionId: string,
previousConnectionId: string
): Promise<RuntimeSettings> {
const operation = this.updateQueue.then(async () => {
const current = await this.load()
if (
current.activeEmbeddingConnectionId !== expectedConnectionId
) {
return this.toPublicSettings(current)
}
const restored = this.withActiveEmbeddingConnection(
current,
previousConnectionId
)
await writeJsonFileAtomically(this.filePath, restored)
this.settings = restored
this.loadWarnings = []
return this.toPublicSettings(restored)
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
private withActiveEmbeddingConnection(
current: StoredSettings,
connectionId: string
): StoredSettings {
const connectionExists = current.embeddingConnections.some(
(candidate) => candidate.id === connectionId
)
if (!connectionExists) {
throw new Error('当前向量连接不存在')
}
return {
...current,
version: CURRENT_SETTINGS_VERSION,
activeEmbeddingConnectionId: connectionId
}
}
private async performUpdate(
input: RuntimeSettingsInput
): Promise<RuntimeSettings> {
+8 -3
View File
@@ -3694,7 +3694,7 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps more than 100 tool activities in one response', async () => {
it('keeps a bounded display copy without stopping a long tool chain', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -3708,7 +3708,7 @@ describe('App', () => {
}
act(() => {
for (let index = 0; index < 101; index += 1) {
for (let index = 0; index < 501; index += 1) {
agentListener?.({
requestId: request.requestId,
type: 'tool',
@@ -3721,7 +3721,12 @@ describe('App', () => {
})
expect(
screen.getByRole('region', { name: '工具执行,共 101 项' })
screen.getByRole('region', { name: '工具执行,共 500 项' })
).toBeInTheDocument()
expect(
screen.getByText(
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
)
).toBeInTheDocument()
})
+82 -17
View File
@@ -128,6 +128,8 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -636,6 +638,9 @@ function appendMessageContentBlock(
}
return current
}
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: crypto.randomUUID(),
type,
@@ -665,6 +670,9 @@ function upsertMessageToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -710,6 +718,9 @@ function upsertMessageSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -738,6 +749,14 @@ function terminalizeMessageToolBlocks(
)
}
function canRetainMessageActivity(message: Message): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string
@@ -1373,6 +1392,7 @@ function toConversationMessage(message: Message): ConversationMessage {
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
displayCaptureTruncated: message.displayCaptureTruncated,
createdAt: message.createdAt,
state: message.state,
status: message.status,
@@ -4316,14 +4336,22 @@ function App(): React.JSX.Element {
maxMessageContentLength - message.content.length
)
const acceptedDelta = event.delta.slice(0, remaining)
const blocks = appendMessageContentBlock(
message.blocks,
'text',
acceptedDelta
)
return {
...message,
content: `${message.content}${acceptedDelta}`,
blocks: appendMessageContentBlock(
message.blocks,
'text',
acceptedDelta
),
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
),
status:
event.delta.length > remaining
? tRef.current('chat.status.responseTruncated')
@@ -4340,15 +4368,23 @@ function App(): React.JSX.Element {
maxMessageContentLength - currentReasoning.length
)
)
const blocks = appendMessageContentBlock(
message.blocks,
'reasoning',
acceptedDelta
)
return {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
status: undefined,
blocks: appendMessageContentBlock(
message.blocks,
'reasoning',
acceptedDelta
)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
)
}
})
} else if (event.type === 'context-metrics') {
@@ -4486,13 +4522,23 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
tools[index] = tool
} else {
} else if (!canRetainMessageActivity(message)) {
return {
...message,
displayCaptureTruncated: true
}
}
if (index < 0) {
tools.push(tool)
}
const blocks = upsertMessageToolBlock(message.blocks, tool)
return {
...message,
tools,
blocks: upsertMessageToolBlock(message.blocks, tool)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
})
} else if (event.type === 'subagent') {
@@ -4623,9 +4669,29 @@ function App(): React.JSX.Element {
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainMessageActivity(message)
) {
return {
...message,
displayCaptureTruncated: true
}
}
subagents.push(subagent)
}
const runtimeCallId = event.runtimeCallId
const blocks = upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId
)
return {
...message,
subagents,
@@ -4634,11 +4700,10 @@ function App(): React.JSX.Element {
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks: upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId
)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
})
} else if (event.type === 'approval') {
+14
View File
@@ -52,6 +52,7 @@ export type Message = {
content: string
reasoning?: string
blocks?: ConversationMessageBlock[]
displayCaptureTruncated?: boolean
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
@@ -941,6 +942,19 @@ function ChatMessageRowView({
value={message.question}
/>
)}
{message.displayCaptureTruncated && (
<div
aria-live="polite"
className="message__status"
role="status"
>
<span
aria-hidden="true"
className="message__status-dot"
/>
{t('chat.status.displayCaptureTruncated')}
</div>
)}
{message.status && (
<div
aria-atomic="true"
+2
View File
@@ -620,6 +620,8 @@ export function McpSettingsSection({
? t('mcp.browser.unsupported')
: !featureAvailable
? t('mcp.builtin.serverSummaryDisabled')
: server.id === 'builtin-browser'
? t('mcp.builtin.serverSummaryExecuteOnly')
: server.access === 'mixed'
? t('mcp.builtin.serverSummaryMixed')
: t('mcp.builtin.serverSummaryReadOnly')}
+40
View File
@@ -1862,6 +1862,43 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
})
it('blocks draft edits while settings are being saved', async () => {
let finishSave!: (settings: RuntimeSettings) => void
updateRuntime.mockImplementationOnce(
async () =>
await new Promise<RuntimeSettings>((resolve) => {
finishSave = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
const settingsBody = screen
.getByRole('tabpanel')
.closest('.settings-panel__body')
await waitFor(() => {
expect(settingsBody).toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'true')
})
act(() => finishSave(runtimeSettings))
await waitFor(() => {
expect(settingsBody).not.toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'false')
})
})
it('submits configured model values while environment values are effective', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
@@ -5123,6 +5160,9 @@ describe('SettingsPanel runtime files', () => {
true
)
)
expect(
within(browserBuiltinCard!).getByText('仅 Execute')
).toBeInTheDocument()
const builtinBrowserToggle = screen.getByRole('button', {
name: '展开服务器 内置浏览器'
})
+5 -1
View File
@@ -2028,7 +2028,11 @@ export function SettingsPanel({
</div>
</div>
<div className="settings-panel__body">
<div
aria-busy={saving || testing}
className="settings-panel__body"
inert={saving || testing ? true : undefined}
>
<nav
aria-label={t('center.categoriesAriaLabel')}
aria-orientation="vertical"
@@ -336,6 +336,8 @@ export const app = {
scrollToBottom: 'Scroll to bottom',
status: {
responseTruncated: 'The response was too long and was truncated locally',
displayCaptureTruncated:
'Later activity details were not retained locally; the Runtime task continued',
savingImage: 'Image generated; saving the result',
taskCompleted: 'Task completed',
taskFailed: 'Task failed',
@@ -211,6 +211,7 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
unsupportedSuffix: ' (not supported yet)',
serverSummaryExecuteOnly: 'Execute only',
serverSummaryMixed: 'Read and write by mode',
serverSummaryReadOnly: 'Read-only',
serverSummaryDisabled: 'Disabled · Enable Magic Notes first',
@@ -319,6 +319,8 @@ export const app = {
scrollToBottom: '到底部',
status: {
responseTruncated: '回答过长,已在本地截断显示',
displayCaptureTruncated:
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',
savingImage: '图片已生成,正在保存结果',
taskCompleted: '任务执行完成',
taskFailed: '任务执行失败',
@@ -195,6 +195,7 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
unsupportedSuffix: '(暂不支持)',
serverSummaryExecuteOnly: '仅 Execute',
serverSummaryMixed: '按模式读写',
serverSummaryReadOnly: '只读',
serverSummaryDisabled: '未启用 · 需要开启魔法笔记',
+33 -1
View File
@@ -174,7 +174,7 @@ describe('project execution space contracts', () => {
})
describe('conversation activity contracts', () => {
it('accepts more than 100 tool and subagent activities', () => {
it('accepts long activity runs within the retained display bounds', () => {
const tools = Array.from({ length: 101 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
@@ -203,6 +203,7 @@ describe('conversation activity contracts', () => {
})),
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true,
tools,
subagents
})
@@ -217,6 +218,37 @@ describe('conversation activity contracts', () => {
])
})
})
it('rejects conversation activity and block arrays beyond display bounds', () => {
const baseMessage = {
id: '00000000-0000-4000-8000-000000000999',
role: 'assistant' as const,
content: '',
createdAt: 1,
state: 'complete' as const
}
expect(
conversationMessageSchema.safeParse({
...baseMessage,
tools: Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
}).success
).toBe(false)
expect(
conversationMessageSchema.safeParse({
...baseMessage,
blocks: Array.from({ length: 2_001 }, (_, index) => ({
id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
type: 'text' as const,
content: 'x'
}))
}).success
).toBe(false)
})
})
describe('conversation branch contracts', () => {
+15 -5
View File
@@ -182,9 +182,12 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
.strict()
])
export const conversationMessageBlocksSchema = z.array(
conversationMessageBlockSchema
)
export const maximumConversationRetainedActivities = 500
export const maximumConversationMessageBlocks = 2_000
export const conversationMessageBlocksSchema = z
.array(conversationMessageBlockSchema)
.max(maximumConversationMessageBlocks)
export type ConversationToolActivity = z.infer<
typeof conversationToolActivitySchema
@@ -265,6 +268,7 @@ export const conversationMessageSchema = z
content: z.string().max(1_000_000),
reasoning: z.string().optional(),
blocks: conversationMessageBlocksSchema.optional(),
displayCaptureTruncated: z.boolean().optional(),
createdAt: z.number().int().nonnegative(),
state: z.enum(['streaming', 'complete', 'error']),
status: z.string().max(4_000).optional(),
@@ -274,8 +278,14 @@ export const conversationMessageSchema = z
.array(conversationContextCompressionMarkerSchema)
.max(2)
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
tools: z
.array(conversationToolActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
subagents: z
.array(conversationSubagentActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
-1
View File
@@ -607,7 +607,6 @@ export {
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
modelRequestBodySchema,
modelRequestHeadersSchema,
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders,
type ModelRequestBody,
@@ -1,135 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
completeModelCallEvidenceSchema,
failModelCallEvidenceSchema,
modelCallDispatchMetadataSchema,
modelCallRecordSchema,
prepareModelCallSchema
} from './model-call-operation-contracts'
const identity = {
callOperationId: 'call-1',
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
provider: 'provider',
profile: 'default',
model: 'model',
protocol: 'responses-v1'
}
const policy = {
modelProfileDigest: `sha256:${'b'.repeat(64)}`
}
describe('model call operation contracts', () => {
it('rejects secret or payload fields at every persistence boundary', () => {
const digest = `sha256:${'a'.repeat(64)}`
expect(() =>
prepareModelCallSchema.parse({
identity,
requestDigest: digest,
...policy,
prompt: 'secret prompt'
})
).toThrow()
expect(() =>
prepareModelCallSchema.parse({
identity: { ...identity, apiKey: 'secret-key' },
requestDigest: digest,
...policy
})
).toThrow()
expect(() =>
modelCallDispatchMetadataSchema.parse({
providerIdempotencyKey: 'safe-id',
requestBody: { messages: [] }
})
).toThrow()
expect(() =>
completeModelCallEvidenceSchema.parse({
status: 'completed',
providerResponseId: 'response-1',
responseBody: 'secret response'
})
).toThrow()
expect(() =>
failModelCallEvidenceSchema.parse({
status: 'failed-definitive',
error: {
code: 'authentication',
retryable: false,
message: 'credential was secret-value'
}
})
).toThrow()
})
it('bounds provider identifiers and safe terminal metadata', () => {
expect(() =>
modelCallDispatchMetadataSchema.parse({
providerRequestId: 'x'.repeat(257)
})
).toThrow()
expect(() =>
completeModelCallEvidenceSchema.parse({
status: 'completed',
result: { finishReason: 'x'.repeat(129) }
})
).toThrow()
})
it('tags terminal evidence and enforces record state consistency', () => {
const base = {
identity,
requestDigest: `sha256:${'a'.repeat(64)}`,
...policy,
preparedAt: 1,
updatedAt: 3,
dispatchedAt: 2,
dispatchMetadata: {}
}
expect(() =>
modelCallRecordSchema.parse({
...base,
status: 'completed',
terminalAt: 3,
terminalEvidence: {
status: 'failed-definitive',
error: { code: 'authentication', retryable: false }
}
})
).toThrow()
expect(() =>
modelCallRecordSchema.parse({
...base,
status: 'prepared'
})
).toThrow()
expect(() =>
modelCallRecordSchema.parse({
...base,
status: 'dispatched',
updatedAt: 1
})
).toThrow()
})
it('rejects prompt-wide quota fields', () => {
const request = {
identity,
requestDigest: `sha256:${'a'.repeat(64)}`,
...policy
}
expect(prepareModelCallSchema.parse(request)).toEqual(request)
expect(() =>
prepareModelCallSchema.parse({
...request,
maximumOutputTokens: 65_537,
maximumModelCalls: 100_000,
maximumTotalOutputTokens: 1_000_000_000
})
).toThrow()
})
})
@@ -1,315 +0,0 @@
import { z } from 'zod'
import {
agentIdentifierSchema,
sha256DigestSchema,
utf8StringSchema
} from './agent-protocol/contracts'
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const positiveCountSchema = countSchema.positive()
const timestampSchema = countSchema
const providerLabelSchema = utf8StringSchema(256, {
minimumBytes: 1,
label: 'Model provider label'
}).refine((value) => value.trim() === value, 'Label must not have outer whitespace')
const providerIdentifierSchema = utf8StringSchema(256, {
minimumBytes: 1,
label: 'Provider identifier'
}).refine(
(value) => value.trim() === value,
'Provider identifier must not have outer whitespace'
)
export const modelCallDigestSchema = sha256DigestSchema
export const modelCallStatusSchema = z.enum([
'prepared',
'dispatched',
'completed',
'failed-definitive',
'outcome-unknown'
])
export type ModelCallStatus = z.infer<typeof modelCallStatusSchema>
export const modelCallIdentitySchema = z
.object({
callOperationId: agentIdentifierSchema,
requestId: agentIdentifierSchema,
bindingId: agentIdentifierSchema,
promptOperationId: agentIdentifierSchema,
promptSequence: z
.number()
.int()
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.default(0),
roundIndex: z
.number()
.int()
.nonnegative()
.max(Number.MAX_SAFE_INTEGER),
provider: providerLabelSchema,
profile: providerLabelSchema,
model: providerLabelSchema,
protocol: providerLabelSchema
})
.strict()
export type ModelCallIdentity = z.infer<typeof modelCallIdentitySchema>
export const prepareModelCallSchema = z
.object({
identity: modelCallIdentitySchema,
requestDigest: modelCallDigestSchema,
modelProfileDigest: modelCallDigestSchema
})
.strict()
export type PrepareModelCall = z.infer<typeof prepareModelCallSchema>
export const modelCallDispatchMetadataSchema = z
.object({
providerIdempotencyKey: providerIdentifierSchema.optional(),
providerRequestId: providerIdentifierSchema.optional()
})
.strict()
export type ModelCallDispatchMetadata = z.infer<
typeof modelCallDispatchMetadataSchema
>
export const modelCallUsageMetadataSchema = z
.object({
inputTokens: countSchema.optional(),
outputTokens: countSchema.optional(),
cachedInputTokens: countSchema.optional(),
reasoningTokens: countSchema.optional()
})
.strict()
export type ModelCallUsageMetadata = z.infer<
typeof modelCallUsageMetadataSchema
>
export const modelCallResultMetadataSchema = z
.object({
finishReason: agentIdentifierSchema.optional(),
outputDigest: modelCallDigestSchema.optional(),
artifactCount: countSchema.max(1_000).optional()
})
.strict()
export type ModelCallResultMetadata = z.infer<
typeof modelCallResultMetadataSchema
>
export const modelCallErrorMetadataSchema = z
.object({
code: agentIdentifierSchema,
retryable: z.boolean()
})
.strict()
export type ModelCallErrorMetadata = z.infer<
typeof modelCallErrorMetadataSchema
>
export const completeModelCallEvidenceSchema = z
.object({
status: z.literal('completed'),
providerRequestId: providerIdentifierSchema.optional(),
providerResponseId: providerIdentifierSchema.optional(),
result: modelCallResultMetadataSchema.optional(),
usage: modelCallUsageMetadataSchema.optional()
})
.strict()
export type CompleteModelCallEvidence = z.infer<
typeof completeModelCallEvidenceSchema
>
export const failModelCallEvidenceSchema = z
.object({
status: z.literal('failed-definitive'),
providerRequestId: providerIdentifierSchema.optional(),
providerResponseId: providerIdentifierSchema.optional(),
error: modelCallErrorMetadataSchema,
usage: modelCallUsageMetadataSchema.optional()
})
.strict()
export type FailModelCallEvidence = z.infer<
typeof failModelCallEvidenceSchema
>
export const unknownModelCallEvidenceSchema = z
.object({
status: z.literal('outcome-unknown'),
providerRequestId: providerIdentifierSchema.optional(),
reason: modelCallErrorMetadataSchema
})
.strict()
export type UnknownModelCallEvidence = z.infer<
typeof unknownModelCallEvidenceSchema
>
export const modelCallTerminalEvidenceSchema = z.discriminatedUnion('status', [
completeModelCallEvidenceSchema,
failModelCallEvidenceSchema,
unknownModelCallEvidenceSchema
])
export type ModelCallTerminalEvidence = z.infer<
typeof modelCallTerminalEvidenceSchema
>
export const modelCallRecordSchema = z
.object({
identity: modelCallIdentitySchema,
requestDigest: modelCallDigestSchema,
modelProfileDigest: modelCallDigestSchema,
status: modelCallStatusSchema,
dispatchMetadata: modelCallDispatchMetadataSchema.optional(),
terminalEvidence: modelCallTerminalEvidenceSchema.optional(),
preparedAt: timestampSchema,
updatedAt: timestampSchema,
dispatchedAt: timestampSchema.optional(),
terminalAt: timestampSchema.optional(),
responseDeliveredAt: timestampSchema.optional()
})
.strict()
.superRefine((record, context) => {
if (record.updatedAt < record.preparedAt) {
addRecordIssue(context, ['updatedAt'], 'updatedAt precedes preparedAt')
}
const hasDispatch =
record.dispatchMetadata !== undefined && record.dispatchedAt !== undefined
if (
(record.dispatchMetadata === undefined) !==
(record.dispatchedAt === undefined)
) {
addRecordIssue(
context,
['dispatchMetadata'],
'dispatch metadata and timestamp must appear together'
)
}
if (
record.dispatchedAt !== undefined &&
(record.dispatchedAt < record.preparedAt ||
record.updatedAt < record.dispatchedAt)
) {
addRecordIssue(
context,
['dispatchedAt'],
'dispatchedAt is outside the record timestamp range'
)
}
if (record.status === 'prepared') {
if (
hasDispatch ||
record.terminalEvidence ||
record.terminalAt !== undefined ||
record.responseDeliveredAt !== undefined
) {
addRecordIssue(
context,
['status'],
'prepared records cannot contain dispatch or terminal state'
)
}
return
}
if (!hasDispatch) {
addRecordIssue(
context,
['dispatchMetadata'],
'dispatched and terminal records require dispatch state'
)
}
if (record.status === 'dispatched') {
if (
record.terminalEvidence ||
record.terminalAt !== undefined ||
record.responseDeliveredAt !== undefined
) {
addRecordIssue(
context,
['status'],
'dispatched records cannot contain terminal state'
)
}
return
}
if (
record.terminalEvidence?.status !== record.status ||
record.terminalAt === undefined
) {
addRecordIssue(
context,
['terminalEvidence'],
'terminal evidence and timestamp must match record status'
)
}
if (
record.terminalAt !== undefined &&
(record.dispatchedAt === undefined ||
record.terminalAt < record.dispatchedAt ||
record.updatedAt < record.terminalAt)
) {
addRecordIssue(
context,
['terminalAt'],
'terminalAt is outside the record timestamp range'
)
}
if (
record.responseDeliveredAt !== undefined &&
(record.status !== 'completed' ||
record.terminalAt === undefined ||
record.responseDeliveredAt < record.terminalAt ||
record.updatedAt < record.responseDeliveredAt)
) {
addRecordIssue(
context,
['responseDeliveredAt'],
'responseDeliveredAt requires a completed provider response'
)
}
})
export type ModelCallRecord = z.infer<typeof modelCallRecordSchema>
export const modelCallOperationCursorSchema = z
.object({
updatedAt: timestampSchema,
callOperationId: agentIdentifierSchema
})
.strict()
export type ModelCallOperationCursor = z.infer<
typeof modelCallOperationCursorSchema
>
export const modelCallDeliveryFilterSchema = z.enum([
'any',
'pending-response',
'delivered'
])
export type ModelCallDeliveryFilter = z.infer<
typeof modelCallDeliveryFilterSchema
>
export const modelCallListOptionsSchema = z
.object({
statuses: z.array(modelCallStatusSchema).min(1).max(5).optional(),
delivery: modelCallDeliveryFilterSchema.default('any'),
cursor: modelCallOperationCursorSchema.optional(),
limit: positiveCountSchema.max(1_000).default(100)
})
.strict()
export type ModelCallListOptions = z.input<
typeof modelCallListOptionsSchema
>
export type ModelCallOperationPage = {
records: ModelCallRecord[]
nextCursor?: ModelCallOperationCursor
}
function addRecordIssue(
context: z.RefinementCtx,
path: Array<string | number>,
message: string
): void {
context.addIssue({ code: 'custom', path, message })
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders,
modelRequestBodySchema,
@@ -29,6 +28,11 @@ describe('model request customization', () => {
'x-tenant': 'two'
})
).toThrow()
expect(() =>
modelRequestHeadersSchema.parse({
'x-tenant': '租户甲'
})
).toThrow()
})
it('accepts bounded JSON bodies and rejects reserved top-level fields', () => {
@@ -83,12 +87,6 @@ describe('model request customization', () => {
'content-type': 'application/json',
'x-tenant': 'custom'
})
expect(canonicalModelRequestHeaders(headers)).toEqual({
accept: 'application/json',
'content-type': 'application/json',
'x-tenant': 'custom'
})
expect(
mergeModelRequestBody(
{
+3 -13
View File
@@ -51,10 +51,10 @@ export type ModelRequestJsonValue = CanonicalJsonValue
export type ModelRequestBody = Record<string, ModelRequestJsonValue>
function hasControlCharacter(value: string): boolean {
function hasInvalidHeaderValueCharacter(value: string): boolean {
return [...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
return code <= 31 || code === 127 || code > 255
})
}
@@ -93,7 +93,7 @@ function isModelRequestHeaders(value: unknown): value is ModelRequestHeaders {
typeof headerValue !== 'string' ||
headerValue.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength ||
hasControlCharacter(headerValue)
hasInvalidHeaderValueCharacter(headerValue)
) {
return false
}
@@ -227,16 +227,6 @@ export function mergeModelRequestHeaders(
return headers
}
export function canonicalModelRequestHeaders(
headers: Headers
): Record<string, string> {
return Object.fromEntries(
[...headers.entries()].sort(([left], [right]) =>
left.localeCompare(right)
)
)
}
export function mergeModelRequestBody(
customBody: ModelRequestBody,
runtimeBody: Record<string, unknown>