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
+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> {