feat: support native subagents and long-running prompts
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
OpenCode Task calls now appear as first-class subagents in local and managed SSH conversations, with stable status, persistence, terminal handling, and true event ordering instead of duplicate generic tool entries. GoodBuddy no longer applies a fixed outer wall-clock total to OpenCode, Continue, DeepSeek Harness, or managed SSH Prompts. Runtime-owned limits, user cancellation, bounded control operations, output limits, and released Agent protocol compatibility remain intact. Desktop and Agent release metadata are synchronized at 0.11.13 with approved bilingual notes. Release note: OpenCode 原生 Task 现在以独立子 Agent 展示;长任务不再被 GoodBuddy 固定总时限中止,并修复 OpenCode 启动识别、可配置 HTTP 服务地址与发布说明读取问题。
This commit is contained in:
@@ -5,6 +5,9 @@ import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import {
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import {
|
||||
launchDirectLinuxStdioProcessOwner,
|
||||
reconcileOrphanedDirectLinuxStdioProcesses,
|
||||
@@ -249,6 +252,48 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('does not stop an active prompt at the unbounded Runtime deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const launched = identity()
|
||||
const sendSignal = vi.fn<typeof process.kill>(() => true)
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-long', processId: 'process-long' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn: () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
},
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => launched,
|
||||
listPidNamespaceMembers: async () => [],
|
||||
sendSignal
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000)
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(await owner.reconcile()).toMatchObject({
|
||||
state: 'running',
|
||||
processTree: 'running'
|
||||
})
|
||||
await owner.stop({
|
||||
reason: 'user-cancelled',
|
||||
deadlineAt: new Date(Date.now() + 10_000).toISOString()
|
||||
})
|
||||
registry.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('startup reconciles only owners from the current installation', async () => {
|
||||
const registry = createRegistry()
|
||||
const reservation = {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
sha256DigestSchema
|
||||
} from '../shared/agent-protocol/contracts'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import {
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import {
|
||||
remoteRuntimeBundleManifestSchema,
|
||||
type RemoteRuntimeBundleManifest
|
||||
@@ -511,6 +514,10 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
#scheduleDeadline(deadlineAt: string): void {
|
||||
if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||
this.#clearDeadline()
|
||||
return
|
||||
}
|
||||
const remaining = remainingMilliseconds(deadlineAt, this.#now())
|
||||
this.#clearDeadline()
|
||||
const generation = this.#promptGeneration
|
||||
@@ -551,7 +558,10 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
now(),
|
||||
limits.maximumPromptRuntimeMilliseconds
|
||||
)
|
||||
const timeoutMs = remainingMilliseconds(effectiveDeadlineAt, now())
|
||||
const timeoutMs =
|
||||
effectiveDeadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
? DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS
|
||||
: remainingMilliseconds(effectiveDeadlineAt, now())
|
||||
const baseProfile = resolveProfile(options, manifest)
|
||||
const ownerToken = (options.randomOwnerToken ?? (() => randomBytes(16).toString('hex')))()
|
||||
if (!/^[a-f0-9]{32}$/u.test(ownerToken)) {
|
||||
@@ -1097,6 +1107,9 @@ function boundedPromptDeadline(
|
||||
now: number,
|
||||
maximumRuntimeMilliseconds: number
|
||||
): string {
|
||||
if (requestedDeadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||
return requestedDeadlineAt
|
||||
}
|
||||
const requested = Date.parse(requestedDeadlineAt)
|
||||
if (!Number.isFinite(requested) || requested <= now) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
type AcpJournalCursor,
|
||||
type RemotePromptOperationPreparation
|
||||
type RemotePromptOperationPreparation,
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
@@ -813,6 +814,28 @@ describe('RuntimeAcpBackend', () => {
|
||||
).rejects.toMatchObject({ code: 'deadline' })
|
||||
})
|
||||
|
||||
it('keeps an unbounded Runtime prompt alive without a wall-clock timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
fixture,
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation({
|
||||
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
})
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 24 * 60 * 60_000)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
await fixture.process.emit('stdout', 'still-running')
|
||||
expect(fixture.outputFrames).toEqual(['still-running'])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops the process when its output quota is exceeded', async () => {
|
||||
const fixture = harness({ maximumOutputBytes: 3 })
|
||||
await open(fixture)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
acpJournalCursorSchema,
|
||||
remotePromptOperationAcceptanceSchema,
|
||||
remotePromptOperationPreparationSchema,
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||
type AcpJournalCursor,
|
||||
type RemotePromptOperationAcceptance,
|
||||
type RemotePromptOperationPreparation
|
||||
@@ -1676,6 +1677,10 @@ export class RuntimeAcpBackend {
|
||||
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
binding.deadlineTimer = undefined
|
||||
}
|
||||
if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||
return
|
||||
}
|
||||
const delay = Math.max(
|
||||
1,
|
||||
|
||||
@@ -259,10 +259,9 @@ function runtime(
|
||||
assertHostCurrent: () => {},
|
||||
cancellationGraceMs,
|
||||
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
||||
promptTimeoutMs:
|
||||
overrides.promptTimeoutMs ??
|
||||
overrides.operationTimeoutMs ??
|
||||
100,
|
||||
...(overrides.promptTimeoutMs === undefined
|
||||
? {}
|
||||
: { promptTimeoutMs: overrides.promptTimeoutMs }),
|
||||
usage: {
|
||||
runtime: 'opencode',
|
||||
provider: 'test',
|
||||
@@ -287,10 +286,9 @@ function factoryRuntime(
|
||||
assertHostCurrent: () => {},
|
||||
cancellationGraceMs: 5,
|
||||
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
||||
promptTimeoutMs:
|
||||
overrides.promptTimeoutMs ??
|
||||
overrides.operationTimeoutMs ??
|
||||
100,
|
||||
...(overrides.promptTimeoutMs === undefined
|
||||
? {}
|
||||
: { promptTimeoutMs: overrides.promptTimeoutMs }),
|
||||
usage: {
|
||||
runtime: 'opencode',
|
||||
provider: 'test',
|
||||
@@ -1258,6 +1256,233 @@ describe('AcpRemoteRuntime', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('merges incremental OpenCode Task input into native subagent events', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'task-1',
|
||||
title: 'Review application architecture',
|
||||
kind: 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: {}
|
||||
}
|
||||
})
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'task-1',
|
||||
status: 'in_progress',
|
||||
rawInput: {
|
||||
subagent_type: 'explorer',
|
||||
description: 'Review application architecture',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
}
|
||||
}
|
||||
})
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'task-1',
|
||||
status: 'in_progress',
|
||||
rawInput: {
|
||||
subagent_type: 'explorer',
|
||||
description: 'Review application architecture',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
}
|
||||
}
|
||||
})
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'task-1',
|
||||
status: 'completed',
|
||||
rawOutput: 'Architecture review complete.'
|
||||
}
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = server.client
|
||||
|
||||
const events = await collect(
|
||||
runtime(server).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'status',
|
||||
'tool',
|
||||
'subagent',
|
||||
'subagent',
|
||||
'done'
|
||||
])
|
||||
expect(events[2]).toMatchObject({
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'task-1',
|
||||
state: 'running'
|
||||
})
|
||||
expect(events[3]).toMatchObject({
|
||||
state: 'completed',
|
||||
output: 'Architecture review complete.'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds native subagent output per event and per request', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'task-bounded',
|
||||
title: 'Bounded task',
|
||||
kind: 'other',
|
||||
status: 'completed',
|
||||
rawInput: {
|
||||
subagent_type: 'explorer',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
},
|
||||
rawOutput: 'x'.repeat(100)
|
||||
}
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = server.client
|
||||
|
||||
const events = await collect(
|
||||
runtime(server, undefined, 5, {
|
||||
maxEventCharacters: 10,
|
||||
maxRequestOutputBytes: 1_000
|
||||
}).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
events.find((event) => event.type === 'subagent')
|
||||
).toMatchObject({
|
||||
state: 'completed',
|
||||
output: 'xxxxx…'
|
||||
})
|
||||
|
||||
const overBudgetServer = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'task-over-budget',
|
||||
title: 'Over-budget task',
|
||||
kind: 'other',
|
||||
status: 'completed',
|
||||
rawInput: {
|
||||
subagent_type: 'explorer',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
},
|
||||
rawOutput: 'x'.repeat(100)
|
||||
}
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = overBudgetServer.client
|
||||
await expect(
|
||||
collect(
|
||||
runtime(overBudgetServer, undefined, 5, {
|
||||
maxEventCharacters: 1_000,
|
||||
maxRequestOutputBytes: 80
|
||||
}).run(
|
||||
{
|
||||
...request,
|
||||
requestId: 'b5b07fa5-f722-4bbc-ad19-33a6435817f1'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
).rejects.toThrow('远端 Runtime 请求输出超过安全上限')
|
||||
})
|
||||
|
||||
it('rejects more than 100 distinct remote tool calls', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
for (let index = 0; index <= 100; index += 1) {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: `call-${index}`,
|
||||
title: 'tool',
|
||||
status: 'in_progress'
|
||||
}
|
||||
})
|
||||
}
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = server.client
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime(server).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'远端 Runtime 单次运行的工具调用超过 100 个'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not impose a default wall-clock deadline on remote prompts', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const server = fakeServer({
|
||||
prompt: async () => {
|
||||
await gate
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
const running = collect(
|
||||
runtime(server).run(
|
||||
request,
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(server.prepare).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000)
|
||||
expect(server.escalate).not.toHaveBeenCalled()
|
||||
release()
|
||||
await expect(running).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'status' }),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes Execute permission through the authorizer and denies mutating Ask permission', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const outcomes: string[] = []
|
||||
@@ -1727,7 +1952,8 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
})
|
||||
const instance = runtime(server, store, 5, {
|
||||
operationTimeoutMs: 20
|
||||
operationTimeoutMs: 20,
|
||||
promptTimeoutMs: 20
|
||||
})
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
import type {
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
assertRemotePromptAcceptanceMatchesPreparation,
|
||||
remotePromptOperationAcceptanceSchema,
|
||||
remotePromptOperationPreparationSchema,
|
||||
runtimeSessionBindingSchema,
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||
type RemotePromptOperationAcceptance,
|
||||
type RemotePromptOperationPreparation,
|
||||
type RuntimeSessionBinding
|
||||
@@ -48,6 +52,10 @@ import {
|
||||
type RemoteRuntimeChannel
|
||||
} from './remote-runtime-channel'
|
||||
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||
import {
|
||||
parseOpenCodeSubagentInput,
|
||||
toOpenCodeSubagentEvent
|
||||
} from './opencode-subagent'
|
||||
|
||||
type BindingIdentity = Pick<
|
||||
RuntimeSessionBinding,
|
||||
@@ -98,6 +106,7 @@ export type AcpRemoteRuntimeOptions = {
|
||||
maxRequestOutputBytes?: number
|
||||
cancellationGraceMs?: number
|
||||
operationTimeoutMs?: number
|
||||
/** Optional test/operator override. Production prompts are Runtime-controlled. */
|
||||
promptTimeoutMs?: number
|
||||
maxPendingUpdates?: number
|
||||
maxPendingUpdateBytes?: number
|
||||
@@ -120,6 +129,16 @@ type ActivePrompt = {
|
||||
pendingUpdateBytes: number
|
||||
inboundPaused: boolean
|
||||
outputCharacters: number
|
||||
toolCalls: Map<
|
||||
string,
|
||||
{
|
||||
title?: string
|
||||
kind?: string
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
rawInput?: unknown
|
||||
rawOutput?: unknown
|
||||
}
|
||||
>
|
||||
open: boolean
|
||||
completed: boolean
|
||||
interruption?: Error
|
||||
@@ -162,7 +181,6 @@ type ChannelContext = {
|
||||
const DEFAULT_MAX_EVENT_CHARACTERS = 100_000
|
||||
const DEFAULT_MAX_REQUEST_OUTPUT_BYTES = 1_000_000
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_PROMPT_TIMEOUT_MS = 120_000
|
||||
const DEFAULT_CANCELLATION_GRACE_MS = 1_500
|
||||
const DEFAULT_MAX_PENDING_UPDATES = 1_000
|
||||
const DEFAULT_MAX_PENDING_UPDATE_BYTES = 4 * 1024 * 1024
|
||||
@@ -188,6 +206,42 @@ function safeStringify(value: unknown, maximum: number): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function boundedRetainedValue(
|
||||
value: unknown,
|
||||
maximum: number
|
||||
): unknown {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > maximum
|
||||
? `${value.slice(0, maximum)}…`
|
||||
: value
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(value)
|
||||
if (serialized === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (serialized.length > maximum) {
|
||||
return `${serialized.slice(0, maximum)}…`
|
||||
}
|
||||
return JSON.parse(serialized) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function boundedRetainedToolInput(
|
||||
value: unknown,
|
||||
maximum: number
|
||||
): unknown {
|
||||
return (
|
||||
parseOpenCodeSubagentInput(value) ??
|
||||
boundedRetainedValue(value, maximum)
|
||||
)
|
||||
}
|
||||
|
||||
class RemoteOperationTimeoutError extends Error {
|
||||
constructor(readonly operation: string) {
|
||||
super(`远端 Runtime ${operation}超时`)
|
||||
@@ -459,9 +513,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
DEFAULT_OPERATION_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private get promptTimeoutMs(): number {
|
||||
return this.options.promptTimeoutMs ??
|
||||
DEFAULT_PROMPT_TIMEOUT_MS
|
||||
private get promptTimeoutMs(): number | undefined {
|
||||
return this.options.promptTimeoutMs
|
||||
}
|
||||
|
||||
private async awaitOperation<T>(
|
||||
@@ -982,9 +1035,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
modelBridge?: RemoteModelBridgeSession
|
||||
): RemotePromptOperationPreparation {
|
||||
const workMode = request.workMode === 'execute' ? 'execute' : 'ask'
|
||||
const deadlineAt = new Date(
|
||||
Date.now() + this.promptTimeoutMs
|
||||
).toISOString()
|
||||
const deadlineAt =
|
||||
this.promptTimeoutMs === undefined
|
||||
? UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
: new Date(Date.now() + this.promptTimeoutMs).toISOString()
|
||||
const budget = {
|
||||
// The Agent applies this to the complete ACP stdin stream, not only
|
||||
// user content. The signed Runtime manifest supplies the effective
|
||||
@@ -1771,6 +1825,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
(event.input?.length ?? 0) +
|
||||
(event.output?.length ?? 0) +
|
||||
(event.error?.length ?? 0)
|
||||
} else if (event.type === 'subagent') {
|
||||
const fieldMaximum = Math.max(1, Math.floor(maximum / 2))
|
||||
const bounded = (value: string | undefined): string | undefined =>
|
||||
value && value.length > fieldMaximum
|
||||
? `${value.slice(0, fieldMaximum)}…`
|
||||
: value
|
||||
event = {
|
||||
...event,
|
||||
output: bounded(event.output),
|
||||
error: bounded(event.error)
|
||||
}
|
||||
characters =
|
||||
(event.reason?.length ?? 0) +
|
||||
(event.output?.length ?? 0) +
|
||||
(event.error?.length ?? 0)
|
||||
} else if (event.type === 'status') {
|
||||
if (event.message.length > maximum) {
|
||||
event = {
|
||||
@@ -1814,22 +1883,87 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
update.sessionUpdate === 'tool_call' ||
|
||||
update.sessionUpdate === 'tool_call_update'
|
||||
) {
|
||||
const callId = update.toolCallId.slice(0, 256)
|
||||
const previous = prompt.toolCalls.get(callId)
|
||||
if (
|
||||
!previous &&
|
||||
prompt.toolCalls.size >=
|
||||
maximumConversationToolActivities
|
||||
) {
|
||||
throw new Error(
|
||||
'远端 Runtime 单次运行的工具调用超过 100 个'
|
||||
)
|
||||
}
|
||||
const retainedMaximum =
|
||||
this.options.maxEventCharacters ??
|
||||
DEFAULT_MAX_EVENT_CHARACTERS
|
||||
const toolCall = {
|
||||
title:
|
||||
update.title === null
|
||||
? undefined
|
||||
: update.title?.slice(0, 200) ?? previous?.title,
|
||||
kind:
|
||||
update.kind === null
|
||||
? undefined
|
||||
: update.kind?.slice(0, 200) ?? previous?.kind,
|
||||
status:
|
||||
update.status === null
|
||||
? undefined
|
||||
: update.status ?? previous?.status,
|
||||
rawInput:
|
||||
update.rawInput === undefined
|
||||
? previous?.rawInput
|
||||
: boundedRetainedToolInput(
|
||||
update.rawInput,
|
||||
retainedMaximum
|
||||
),
|
||||
rawOutput:
|
||||
update.rawOutput === undefined
|
||||
? previous?.rawOutput
|
||||
: boundedRetainedValue(
|
||||
update.rawOutput,
|
||||
retainedMaximum
|
||||
)
|
||||
}
|
||||
if (previous && isDeepStrictEqual(previous, toolCall)) {
|
||||
return undefined
|
||||
}
|
||||
prompt.toolCalls.set(callId, toolCall)
|
||||
const name = (
|
||||
update.title ??
|
||||
update.kind ??
|
||||
toolCall.title ??
|
||||
toolCall.kind ??
|
||||
'远端 Runtime 工具'
|
||||
).slice(0, 200)
|
||||
const input = safeStringify(update.rawInput, 100_000)
|
||||
const output = safeStringify(update.rawOutput, 100_000)
|
||||
const subagent =
|
||||
this.options.runtimeId === 'opencode'
|
||||
? toOpenCodeSubagentEvent({
|
||||
requestId: prompt.requestId,
|
||||
callId,
|
||||
state: toolCall.status ?? 'pending',
|
||||
input: toolCall.rawInput,
|
||||
output: toolCall.rawOutput
|
||||
})
|
||||
: undefined
|
||||
if (subagent) {
|
||||
return subagent
|
||||
}
|
||||
const input = safeStringify(
|
||||
toolCall.rawInput,
|
||||
retainedMaximum
|
||||
)
|
||||
const output = safeStringify(
|
||||
toolCall.rawOutput,
|
||||
retainedMaximum
|
||||
)
|
||||
return {
|
||||
requestId: prompt.requestId,
|
||||
type: 'tool',
|
||||
callId: update.toolCallId.slice(0, 256),
|
||||
callId,
|
||||
name,
|
||||
state:
|
||||
update.status === 'in_progress'
|
||||
toolCall.status === 'in_progress'
|
||||
? 'running'
|
||||
: update.status ?? 'pending',
|
||||
: toolCall.status ?? 'pending',
|
||||
summary: `远端 Runtime 工具:${name}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {})
|
||||
@@ -1925,6 +2059,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
pendingUpdateBytes: 0,
|
||||
inboundPaused: false,
|
||||
outputCharacters: 0,
|
||||
toolCalls: new Map(),
|
||||
open: true,
|
||||
completed: false,
|
||||
context
|
||||
@@ -1993,10 +2128,12 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt.wake?.()
|
||||
prompt.wake = undefined
|
||||
})
|
||||
promptTimeout = setTimeout(
|
||||
() => interrupt(new RemoteOperationTimeoutError('执行请求')),
|
||||
this.promptTimeoutMs
|
||||
)
|
||||
if (this.promptTimeoutMs !== undefined) {
|
||||
promptTimeout = setTimeout(
|
||||
() => interrupt(new RemoteOperationTimeoutError('执行请求')),
|
||||
this.promptTimeoutMs
|
||||
)
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
|
||||
@@ -357,6 +357,9 @@ describe('ContinueHostAdapter', () => {
|
||||
})
|
||||
|
||||
it('launches the prepared host through the injected launcher', async () => {
|
||||
let now = Date.now()
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
environmentRestorations.push(() => nowSpy.mockRestore())
|
||||
const distribution = await createDistribution()
|
||||
const skillDirectory = join(
|
||||
distribution.cacheRoot,
|
||||
@@ -445,11 +448,14 @@ describe('ContinueHostAdapter', () => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/state')) {
|
||||
stateRequests += 1
|
||||
if (stateRequests === 2) {
|
||||
now += 25 * 60 * 60_000
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
session: {
|
||||
history:
|
||||
stateRequests === 1
|
||||
stateRequests <= 2
|
||||
? []
|
||||
: [
|
||||
{
|
||||
@@ -460,7 +466,7 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
],
|
||||
usage:
|
||||
stateRequests === 1
|
||||
stateRequests <= 2
|
||||
? {
|
||||
promptTokens: 20,
|
||||
completionTokens: 10,
|
||||
@@ -478,7 +484,7 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
isProcessing: false,
|
||||
isProcessing: stateRequests === 2,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null
|
||||
})
|
||||
@@ -605,6 +611,90 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(existsSync(generatedConfigPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds each host control request without bounding the full run', async () => {
|
||||
const distribution = await createDistribution()
|
||||
let killed = false
|
||||
const launchHost: ContinueHostLauncher = () => ({
|
||||
exitCode: null,
|
||||
get killed() {
|
||||
return killed
|
||||
},
|
||||
stderr: null,
|
||||
once: () => undefined,
|
||||
kill: () => {
|
||||
killed = true
|
||||
return true
|
||||
}
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
if (String(input).endsWith('/state')) {
|
||||
return Response.json({
|
||||
session: { history: [] },
|
||||
isProcessing: false,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null
|
||||
})
|
||||
}
|
||||
if (String(input).endsWith('/message')) {
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
const rejectOnAbort = (): void =>
|
||||
reject(init?.signal?.reason)
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
rejectOnAbort,
|
||||
{ once: true }
|
||||
)
|
||||
if (init?.signal?.aborted) {
|
||||
rejectOnAbort()
|
||||
}
|
||||
})
|
||||
}
|
||||
return Response.json({})
|
||||
}
|
||||
)
|
||||
)
|
||||
const adapter = new ContinueHostAdapter(
|
||||
{
|
||||
binaryPath: distribution.entryPath,
|
||||
configPath: '',
|
||||
workspace: process.cwd(),
|
||||
cacheRoot: distribution.cacheRoot,
|
||||
trustedBundleHashes: [distribution.sourceHash],
|
||||
launchHost,
|
||||
modelProfile: {
|
||||
id: '00000000-0000-4000-8000-000000000012',
|
||||
name: '独立模型',
|
||||
baseUrl: 'https://model.example',
|
||||
modelName: 'private-model',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
apiKey: 'private-key'
|
||||
}
|
||||
},
|
||||
{
|
||||
controlRequestTimeoutMs: 5,
|
||||
terminateProcessTree: vi.fn(async (child) => {
|
||||
child.kill()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
async () => 'deny'
|
||||
)
|
||||
).rejects.toThrow('Continue 宿主请求超时(/message)')
|
||||
expect(killed).toBe(true)
|
||||
})
|
||||
|
||||
it('injects scoped knowledge into a temporary copy of a JSONC config', async () => {
|
||||
const distribution = await createDistribution()
|
||||
const configPath = join(
|
||||
|
||||
@@ -26,6 +26,9 @@ import type {
|
||||
RuntimeNativeSnapshot,
|
||||
RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
continueConfigurationPresetSchema,
|
||||
runtimeNativeInventoryLimits,
|
||||
@@ -66,8 +69,8 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
|
||||
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
|
||||
const maximumStreamEvents = 5_000
|
||||
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||
const maximumToolCalls = 100
|
||||
const maximumExecutionMilliseconds = 10 * 60_000
|
||||
const maximumToolCalls = maximumConversationToolActivities
|
||||
const defaultControlRequestTimeoutMs = 30_000
|
||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||
const customMcpName = 'goodbuddy-custom-mcp'
|
||||
export const continueConfigurationRequiredMessage =
|
||||
@@ -246,6 +249,7 @@ export type ContinueHostAdapterDependencies = {
|
||||
maximumStreamEvents: number
|
||||
maximumStreamEventBytes: number
|
||||
maximumToolCalls: number
|
||||
controlRequestTimeoutMs: number
|
||||
}
|
||||
|
||||
export type ContinueHostRunOptions = {
|
||||
@@ -830,6 +834,7 @@ export class ContinueHostAdapter {
|
||||
maximumStreamEvents,
|
||||
maximumStreamEventBytes,
|
||||
maximumToolCalls,
|
||||
controlRequestTimeoutMs: defaultControlRequestTimeoutMs,
|
||||
...dependencies
|
||||
}
|
||||
}
|
||||
@@ -1140,16 +1145,32 @@ export class ContinueHostAdapter {
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init.headers
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: init.signal
|
||||
})
|
||||
const timeoutSignal = AbortSignal.timeout(
|
||||
Math.max(1, this.dependencies.controlRequestTimeoutMs)
|
||||
)
|
||||
const requestSignal = init.signal
|
||||
? AbortSignal.any([init.signal, timeoutSignal])
|
||||
: timeoutSignal
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${origin}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init.headers
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: requestSignal
|
||||
})
|
||||
} catch (error) {
|
||||
if (timeoutSignal.aborted && !init.signal?.aborted) {
|
||||
throw new Error(`Continue 宿主请求超时(${path})`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const body = await readBoundedResponseText(response, {
|
||||
maxBytes: maximumStateBytes,
|
||||
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
|
||||
@@ -1559,7 +1580,6 @@ export class ContinueHostAdapter {
|
||||
let streamEventCount = 0
|
||||
let streamEventBytes = 0
|
||||
const observedToolCallIds = new Set<string>()
|
||||
let executionTimeoutSignal: AbortSignal | undefined
|
||||
try {
|
||||
const initialState = await this.waitForStartup(
|
||||
child,
|
||||
@@ -1569,13 +1589,7 @@ export class ContinueHostAdapter {
|
||||
signal
|
||||
)
|
||||
const startIndex = initialState.session.history.length
|
||||
executionTimeoutSignal = AbortSignal.timeout(
|
||||
maximumExecutionMilliseconds
|
||||
)
|
||||
const executionSignal = AbortSignal.any([
|
||||
signal,
|
||||
executionTimeoutSignal
|
||||
])
|
||||
const executionSignal = signal
|
||||
const message =
|
||||
runOptions.images && runOptions.images.length > 0
|
||||
? [
|
||||
@@ -1598,9 +1612,8 @@ export class ContinueHostAdapter {
|
||||
signal: executionSignal
|
||||
})
|
||||
|
||||
const expiresAt = Date.now() + maximumExecutionMilliseconds
|
||||
const handledPermissionIds = new Set<string>()
|
||||
while (Date.now() < expiresAt) {
|
||||
while (true) {
|
||||
executionSignal.throwIfAborted()
|
||||
if (childFailure) {
|
||||
throw childFailure
|
||||
@@ -1817,20 +1830,15 @@ export class ContinueHostAdapter {
|
||||
}
|
||||
await delay(150, executionSignal)
|
||||
}
|
||||
throw new Error('Continue 宿主执行超时')
|
||||
} catch (error) {
|
||||
if (error instanceof ContinueHostRunError) {
|
||||
throw error
|
||||
}
|
||||
const normalizedError =
|
||||
executionTimeoutSignal?.aborted && !signal.aborted
|
||||
? new Error('Continue 宿主执行超时', { cause: error })
|
||||
: error
|
||||
throw new ContinueHostRunError(
|
||||
normalizedError instanceof Error
|
||||
? normalizedError.message
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Continue 宿主执行失败',
|
||||
{ cause: normalizedError, tools: observedTools }
|
||||
{ cause: error, tools: observedTools }
|
||||
)
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
|
||||
@@ -44,6 +44,7 @@ function setup(
|
||||
nativeTools?: Array<Record<string, unknown>>
|
||||
toolsSupported?: boolean
|
||||
promptTimeoutMs?: number
|
||||
useProductionPromptTimeout?: boolean
|
||||
maxEventCharacters?: number
|
||||
maxRequestOutputCharacters?: number
|
||||
supportsImageInput?: boolean
|
||||
@@ -51,6 +52,7 @@ function setup(
|
||||
initializationTimeoutMs?: number
|
||||
useDefaultInitializationTimeout?: boolean
|
||||
launchDelayMs?: number
|
||||
prepare?: () => Promise<Record<string, unknown>>
|
||||
extensionPackages?: Array<{
|
||||
id: string
|
||||
entrypoint: string
|
||||
@@ -170,7 +172,7 @@ function setup(
|
||||
}
|
||||
}
|
||||
if (method === 'goodbuddy/session/prepare') {
|
||||
return { prepared: true }
|
||||
return options.prepare?.() ?? { prepared: true }
|
||||
}
|
||||
if (method === 'goodbuddy/session/release') {
|
||||
return { released: true }
|
||||
@@ -253,7 +255,9 @@ function setup(
|
||||
initializationTimeoutMs:
|
||||
options.initializationTimeoutMs ?? 100
|
||||
}),
|
||||
promptTimeoutMs: options.promptTimeoutMs ?? 100,
|
||||
...(options.useProductionPromptTimeout
|
||||
? {}
|
||||
: { promptTimeoutMs: options.promptTimeoutMs ?? 100 }),
|
||||
shutdownTimeoutMs: 10,
|
||||
maxStderrBytes: 16,
|
||||
maxEventCharacters: options.maxEventCharacters,
|
||||
@@ -1347,6 +1351,62 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not submit a prompt after cancellation during preparation', async () => {
|
||||
const prepare = deferred<Record<string, unknown>>()
|
||||
const harness = setup({ prepare: () => prepare.promise })
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('cancel-during-prepare'),
|
||||
controller.signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.requests.some(
|
||||
({ method }) => method === 'goodbuddy/session/prepare'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
controller.abort(new Error('cancelled during preparation'))
|
||||
prepare.resolve({ prepared: true })
|
||||
|
||||
await expect(running).rejects.toThrow(
|
||||
'cancelled during preparation'
|
||||
)
|
||||
expect(harness.agent.prompt).not.toHaveBeenCalled()
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bounds cancellation when the harness prompt never settles', async () => {
|
||||
const harness = setup({ useProductionPromptTimeout: true })
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('uncooperative-cancel'),
|
||||
controller.signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails on bounded stderr overflow without exposing stderr text', async () => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
@@ -1420,4 +1480,32 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
})
|
||||
await expect(harness.runtime.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not impose a default wall-clock deadline on prompts', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = setup({ useProductionPromptTimeout: true })
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('long-running'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000)
|
||||
expect(harness.notifications).not.toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await expect(running).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'status' }),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protoco
|
||||
|
||||
const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk'
|
||||
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_PROMPT_TIMEOUT_MS = 10 * 60_000
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024
|
||||
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
|
||||
@@ -486,8 +485,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private get promptTimeoutMs(): number {
|
||||
return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS
|
||||
private get promptTimeoutMs(): number | undefined {
|
||||
return this.options.promptTimeoutMs
|
||||
}
|
||||
|
||||
private get shutdownTimeoutMs(): number {
|
||||
@@ -1435,6 +1434,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
} else {
|
||||
toolNames.delete(callId)
|
||||
}
|
||||
const input = safeStringify(update.rawInput)
|
||||
const output = safeStringify(update.rawOutput)
|
||||
return {
|
||||
requestId,
|
||||
type: 'tool',
|
||||
@@ -1442,12 +1443,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
name,
|
||||
state,
|
||||
summary: `DeepSeek Harness 工具:${name}`,
|
||||
...(safeStringify(update.rawInput)
|
||||
? { input: safeStringify(update.rawInput) }
|
||||
: {}),
|
||||
...(safeStringify(update.rawOutput)
|
||||
? { output: safeStringify(update.rawOutput) }
|
||||
: {})
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {})
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -1505,8 +1502,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
| { stopReason?: string }
|
||||
| undefined
|
||||
let promptError: unknown
|
||||
const promptController = new AbortController()
|
||||
const cancel = (): void => {
|
||||
run!.closed = true
|
||||
run!.wake?.()
|
||||
run!.wake = undefined
|
||||
void state!.agent
|
||||
.cancel({
|
||||
sessionId: sessionId!
|
||||
@@ -1514,33 +1513,47 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
.catch(() => undefined)
|
||||
}
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
await withTimeout(
|
||||
state.agent.extMethod(GOODBUDDY_PREPARE, {
|
||||
sessionId,
|
||||
requestId: request.requestId,
|
||||
mode:
|
||||
request.workMode === 'execute' ? 'execute' : 'ask'
|
||||
}),
|
||||
this.initializationTimeoutMs,
|
||||
'请求准备'
|
||||
)
|
||||
const prompt = withTimeout(
|
||||
state.agent.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: 'text',
|
||||
text: flattenPrompt(request)
|
||||
},
|
||||
...(request.images ?? []).map((image) => ({
|
||||
type: 'image' as const,
|
||||
data: image.data,
|
||||
mimeType: image.mediaType
|
||||
}))
|
||||
]
|
||||
}),
|
||||
this.promptTimeoutMs,
|
||||
'请求'
|
||||
if (signal.aborted) {
|
||||
cancel()
|
||||
}
|
||||
try {
|
||||
await withTimeout(
|
||||
state.agent.extMethod(GOODBUDDY_PREPARE, {
|
||||
sessionId,
|
||||
requestId: request.requestId,
|
||||
mode:
|
||||
request.workMode === 'execute' ? 'execute' : 'ask'
|
||||
}),
|
||||
this.initializationTimeoutMs,
|
||||
'请求准备'
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
} catch (error) {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
throw error
|
||||
}
|
||||
const promptOperation = state.agent.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: 'text',
|
||||
text: flattenPrompt(request)
|
||||
},
|
||||
...(request.images ?? []).map((image) => ({
|
||||
type: 'image' as const,
|
||||
data: image.data,
|
||||
mimeType: image.mediaType
|
||||
}))
|
||||
]
|
||||
})
|
||||
const prompt = (
|
||||
this.promptTimeoutMs === undefined
|
||||
? promptOperation
|
||||
: withTimeout(
|
||||
promptOperation,
|
||||
this.promptTimeoutMs,
|
||||
'请求'
|
||||
)
|
||||
)
|
||||
.then((value) => {
|
||||
response = value
|
||||
@@ -1555,9 +1568,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
})
|
||||
try {
|
||||
while (!completed || run.updates.length > 0) {
|
||||
signal.throwIfAborted()
|
||||
if (this.fatalError) {
|
||||
throw this.fatalError
|
||||
}
|
||||
if (run.closed && !completed) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 请求在完成前已关闭'
|
||||
)
|
||||
}
|
||||
if (run.updates.length === 0) {
|
||||
await this.waitForUpdate(run)
|
||||
continue
|
||||
@@ -1588,11 +1607,21 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
sessionId
|
||||
}
|
||||
} finally {
|
||||
promptController.abort(
|
||||
new Error('DeepSeek Harness 流式消费已结束')
|
||||
)
|
||||
if (!completed) {
|
||||
cancel()
|
||||
}
|
||||
signal.removeEventListener('abort', cancel)
|
||||
await prompt
|
||||
if (completed) {
|
||||
await prompt
|
||||
} else {
|
||||
await withTimeout(
|
||||
prompt,
|
||||
this.shutdownTimeoutMs,
|
||||
'取消请求'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortTools)
|
||||
|
||||
@@ -58,10 +58,6 @@ function fakeClient() {
|
||||
} as unknown as ReturnType<typeof createOpencodeClient>
|
||||
}
|
||||
|
||||
function stdoutOf(child: SpawnedProcess): PassThrough {
|
||||
return child.stdout as PassThrough
|
||||
}
|
||||
|
||||
function stderrOf(child: SpawnedProcess): PassThrough {
|
||||
return child.stderr as PassThrough
|
||||
}
|
||||
@@ -261,19 +257,16 @@ function embeddedRuntime(
|
||||
client: ReturnType<typeof createOpencodeClient>,
|
||||
overrides: Partial<
|
||||
ConstructorParameters<typeof OpenCodeRuntime>[0]
|
||||
> = {}
|
||||
> = {},
|
||||
dependencyOverrides: Partial<OpenCodeRuntimeDependencies> = {}
|
||||
): OpenCodeRuntime {
|
||||
const child = fakeChild()
|
||||
const { deps } = dependencies(child, {
|
||||
...dependencyOverrides,
|
||||
createClient: vi.fn(
|
||||
() => client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
})
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:4010\n'
|
||||
)
|
||||
}, 0)
|
||||
return new OpenCodeRuntime(options(overrides), deps)
|
||||
}
|
||||
|
||||
@@ -459,11 +452,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
)
|
||||
const child = fakeChild()
|
||||
const { deps, spawnMock } = dependencies(child)
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:3012\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
skillPackages: [
|
||||
@@ -537,11 +525,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
it('injects an independent model profile without persisting its key', async () => {
|
||||
const child = fakeChild()
|
||||
const { deps, spawnMock } = dependencies(child)
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:3011\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
modelProfile: {
|
||||
@@ -615,11 +598,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
])
|
||||
)
|
||||
Object.assign(process.env, inheritedCredentials)
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:3013\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
modelProfile: {
|
||||
@@ -744,7 +722,8 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
authentication: 'api-key',
|
||||
apiKey: 'local-probe-key'
|
||||
}
|
||||
})
|
||||
}),
|
||||
{ startupTimeoutMs: 20_000 }
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
@@ -822,11 +801,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
}) => {
|
||||
const child = fakeChild()
|
||||
const { deps, spawnMock } = dependencies(child)
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:3012\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
modelProfile: {
|
||||
@@ -913,11 +887,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
'https://telemetry.invalid'
|
||||
try {
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:3010\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true
|
||||
@@ -1055,7 +1024,9 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
|
||||
it('terminates startup when the request is aborted', async () => {
|
||||
const child = fakeChild()
|
||||
const { deps, spawnMock } = dependencies(child)
|
||||
const { deps, spawnMock } = dependencies(child, {
|
||||
checkServerHealth: vi.fn().mockResolvedValue(false)
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
const controller = new AbortController()
|
||||
const stream = runtime.run(
|
||||
@@ -1075,6 +1046,25 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
})
|
||||
|
||||
it('bounds session setup without imposing a full-run deadline', async () => {
|
||||
const harness = runClient([])
|
||||
;(
|
||||
harness.session.create as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(
|
||||
() => new Promise(() => undefined)
|
||||
)
|
||||
const runtime = embeddedRuntime(
|
||||
harness.client,
|
||||
{},
|
||||
{ controlRequestTimeoutMs: 5 }
|
||||
)
|
||||
|
||||
await expect(collectRun(runtime)).rejects.toThrow(
|
||||
'OpenCode 创建会话超时'
|
||||
)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps external baseUrl mode free of binary detection and spawning', async () => {
|
||||
const child = fakeChild()
|
||||
const { deps, spawnMock, detectBinary, createClient } = dependencies(child)
|
||||
@@ -1538,11 +1528,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
? questionEvent.value.questionId
|
||||
: ''
|
||||
await runtime.respondToQuestion(questionId, [['先写测试']])
|
||||
expect(setup.questionReply).toHaveBeenCalledWith({
|
||||
requestID: 'question-1',
|
||||
directory: process.cwd(),
|
||||
answers: [['先写测试']]
|
||||
})
|
||||
expect(setup.questionReply).toHaveBeenCalledWith(
|
||||
{
|
||||
requestID: 'question-1',
|
||||
directory: process.cwd(),
|
||||
answers: [['先写测试']]
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'done' }
|
||||
})
|
||||
@@ -1631,20 +1624,212 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
runtime.respondToQuestion(secondId, [['second answer']])
|
||||
])
|
||||
expect(setup.questionReply).toHaveBeenCalledTimes(2)
|
||||
expect(setup.questionReply).toHaveBeenNthCalledWith(1, {
|
||||
requestID: 'shared-question',
|
||||
directory: process.cwd(),
|
||||
answers: [['first answer']]
|
||||
})
|
||||
expect(setup.questionReply).toHaveBeenNthCalledWith(2, {
|
||||
requestID: 'shared-question',
|
||||
directory: process.cwd(),
|
||||
answers: [['second answer']]
|
||||
})
|
||||
expect(setup.questionReply).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
requestID: 'shared-question',
|
||||
directory: process.cwd(),
|
||||
answers: [['first answer']]
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(setup.questionReply).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
requestID: 'shared-question',
|
||||
directory: process.cwd(),
|
||||
answers: [['second answer']]
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
await Promise.all([first.next(), second.next()])
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('emits native Task calls as subagents instead of generic tools', async () => {
|
||||
const taskInput = {
|
||||
subagent_type: 'explorer',
|
||||
description: 'Review application architecture',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
}
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'task-running',
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
part: {
|
||||
id: 'part-task-1',
|
||||
callID: 'call-task-1',
|
||||
type: 'tool',
|
||||
tool: 'task',
|
||||
state: {
|
||||
status: 'running',
|
||||
input: taskInput,
|
||||
time: { start: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'task-completed',
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
part: {
|
||||
id: 'part-task-1',
|
||||
callID: 'call-task-1',
|
||||
type: 'tool',
|
||||
tool: 'task',
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: taskInput,
|
||||
output: 'Architecture review complete.',
|
||||
title: 'Review application architecture',
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
|
||||
const events = await collectRun(runtime)
|
||||
const subagents = events.filter(
|
||||
(event) => event.type === 'subagent'
|
||||
)
|
||||
|
||||
expect(subagents).toHaveLength(2)
|
||||
expect(subagents[0]).toMatchObject({
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'running',
|
||||
reason: 'Review application architecture'
|
||||
})
|
||||
expect(subagents[1]).toMatchObject({
|
||||
childTaskId:
|
||||
subagents[0]?.type === 'subagent'
|
||||
? subagents[0].childTaskId
|
||||
: undefined,
|
||||
state: 'completed',
|
||||
output: 'Architecture review complete.'
|
||||
})
|
||||
expect(events.some((event) => event.type === 'tool')).toBe(false)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps non-Task tools with Task-shaped input as generic tools', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'custom-tool-completed',
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
part: {
|
||||
id: 'part-custom-1',
|
||||
callID: 'call-custom-1',
|
||||
type: 'tool',
|
||||
tool: 'custom_delegate',
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: {
|
||||
subagent_type: 'explorer',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
},
|
||||
output: 'Custom tool result.',
|
||||
title: 'Custom delegate',
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
|
||||
const events = await collectRun(runtime)
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-custom-1',
|
||||
name: 'custom_delegate',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
expect(events.some((event) => event.type === 'subagent')).toBe(false)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not impose a default wall-clock deadline on OpenCode runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const setup = runClient([])
|
||||
;(
|
||||
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
await gate
|
||||
yield {
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
})()
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
embedded: false
|
||||
}),
|
||||
dependencies(fakeChild(), {
|
||||
createClient: vi.fn(
|
||||
() => setup.client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
}).deps
|
||||
)
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'work for more than a day',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }
|
||||
})
|
||||
const pending = stream.next()
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000)
|
||||
expect(setup.session.abort).not.toHaveBeenCalled()
|
||||
release()
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
value: { type: 'done' }
|
||||
})
|
||||
await runtime.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts an OpenCode run at its total execution deadline', async () => {
|
||||
const setup = runClient([])
|
||||
;(
|
||||
@@ -1722,11 +1907,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
executionTimeoutMs: 20
|
||||
}).deps
|
||||
)
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:4010\n'
|
||||
)
|
||||
}, 0)
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
@@ -1903,11 +2083,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
() => setup.client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
})
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:4010\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({ knowledgeGateway: gateway }),
|
||||
deps
|
||||
@@ -2004,11 +2179,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
() => setup.client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
})
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:4010\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
knowledgeGateway: {
|
||||
@@ -2107,11 +2277,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
() => setup.client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
})
|
||||
setTimeout(() => {
|
||||
stdoutOf(child).write(
|
||||
'opencode server listening on http://127.0.0.1:4010\n'
|
||||
)
|
||||
}, 0)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
knowledgeGateway: {
|
||||
@@ -2357,11 +2522,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(permissionReply).toHaveBeenCalledOnce()
|
||||
expect(permissionReply).toHaveBeenCalledWith({
|
||||
requestID: 'permission-1',
|
||||
directory: process.cwd(),
|
||||
reply: 'once'
|
||||
})
|
||||
expect(permissionReply).toHaveBeenCalledWith(
|
||||
{
|
||||
requestID: 'permission-1',
|
||||
directory: process.cwd(),
|
||||
reply: 'once'
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
@@ -2453,14 +2621,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
requestID: 'permission-1',
|
||||
directory: process.cwd(),
|
||||
reply: 'once'
|
||||
}
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
],
|
||||
[
|
||||
{
|
||||
requestID: 'permission-2',
|
||||
directory: process.cwd(),
|
||||
reply: 'once'
|
||||
}
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
]
|
||||
])
|
||||
await runtime.dispose()
|
||||
@@ -2626,10 +2796,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
await collectRun(runtime)
|
||||
await runtime.releaseConversation('conversation-1')
|
||||
|
||||
expect(session.delete).toHaveBeenCalledWith({
|
||||
sessionID: 'session-1',
|
||||
directory: process.cwd()
|
||||
})
|
||||
expect(session.delete).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionID: 'session-1',
|
||||
directory: process.cwd()
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -2649,11 +2822,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
await collectRun(runtime, 'execute')
|
||||
|
||||
expect(permissionReply).toHaveBeenCalledOnce()
|
||||
expect(permissionReply).toHaveBeenCalledWith({
|
||||
requestID: 'permission-1',
|
||||
directory: process.cwd(),
|
||||
reply: 'reject'
|
||||
})
|
||||
expect(permissionReply).toHaveBeenCalledWith(
|
||||
{
|
||||
requestID: 'permission-1',
|
||||
directory: process.cwd(),
|
||||
reply: 'reject'
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
+410
-172
@@ -19,8 +19,12 @@ import { join, resolve } from 'node:path'
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentRuntimeStatus
|
||||
AgentRuntimeStatus,
|
||||
SubagentEvent
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
maximumConversationToolActivities
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
boundedRuntimeIdentifierSchema,
|
||||
runtimeNativeInventoryLimits,
|
||||
@@ -60,18 +64,19 @@ import {
|
||||
requestProcessTreeTermination,
|
||||
waitForProcessExit
|
||||
} from './child-process-termination'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 10_000
|
||||
const STARTUP_POLL_INTERVAL_MS = 100
|
||||
const STARTUP_PROBE_TIMEOUT_MS = 500
|
||||
const CONTROL_REQUEST_TIMEOUT_MS = 30_000
|
||||
const MAX_PERMISSION_NAME_LENGTH = 128
|
||||
const MAX_PERMISSION_PATTERNS = 32
|
||||
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
||||
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
|
||||
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
|
||||
const MAX_TOOL_CALLS_PER_RUN = 100
|
||||
const MAX_TOOL_CALLS_PER_RUN = maximumConversationToolActivities
|
||||
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
|
||||
const MAX_EXECUTION_MILLISECONDS = 10 * 60_000
|
||||
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
|
||||
const MAX_QUESTIONS_PER_REQUEST = 4
|
||||
const MAX_QUESTION_OPTIONS = 20
|
||||
@@ -466,7 +471,8 @@ export type OpenCodeRuntimeDependencies = {
|
||||
) => Promise<boolean>
|
||||
platform: NodeJS.Platform
|
||||
startupTimeoutMs: number
|
||||
executionTimeoutMs: number
|
||||
controlRequestTimeoutMs: number
|
||||
executionTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type OpenCodeRuntimeOptions = {
|
||||
@@ -723,7 +729,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
checkServerHealth: defaultCheckServerHealth,
|
||||
platform: process.platform,
|
||||
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
||||
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
|
||||
controlRequestTimeoutMs: CONTROL_REQUEST_TIMEOUT_MS,
|
||||
...dependencies
|
||||
}
|
||||
}
|
||||
@@ -1115,6 +1121,51 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private async controlRequest<T>(
|
||||
label: string,
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
callerSignal?: AbortSignal,
|
||||
onAbortedResult?: (value: T) => void | Promise<void>
|
||||
): Promise<T> {
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutController.abort(
|
||||
new Error(`OpenCode ${label}超时`)
|
||||
)
|
||||
}, Math.max(1, this.dependencies.controlRequestTimeoutMs))
|
||||
timeout.unref?.()
|
||||
const controlSignal = callerSignal
|
||||
? AbortSignal.any([callerSignal, timeoutController.signal])
|
||||
: timeoutController.signal
|
||||
try {
|
||||
controlSignal.throwIfAborted()
|
||||
const request = operation(controlSignal)
|
||||
if (onAbortedResult) {
|
||||
void request
|
||||
.then(async (value) => {
|
||||
if (controlSignal.aborted) {
|
||||
await onAbortedResult(value)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return await awaitWithAbort(
|
||||
request,
|
||||
controlSignal
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
timeoutController.signal.aborted &&
|
||||
!callerSignal?.aborted
|
||||
) {
|
||||
throw timeoutController.signal.reason
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeClient(
|
||||
signal?: AbortSignal
|
||||
): Promise<OpencodeClient> {
|
||||
@@ -1148,9 +1199,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
try {
|
||||
const client = await this.getClient()
|
||||
const response = await client.session.list({
|
||||
directory: this.options.defaultWorkspace
|
||||
})
|
||||
const response = await this.controlRequest(
|
||||
'连接检查',
|
||||
(signal) =>
|
||||
client.session.list(
|
||||
{ directory: this.options.defaultWorkspace },
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('OpenCode Server 返回错误')
|
||||
@@ -1189,15 +1245,17 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
hidden: boolean
|
||||
}>
|
||||
> {
|
||||
const operation = client.app.agents(
|
||||
{
|
||||
directory: this.options.defaultWorkspace
|
||||
},
|
||||
signal ? { signal } : undefined
|
||||
const response = await this.controlRequest(
|
||||
'读取 Agent 清单',
|
||||
(controlSignal) =>
|
||||
client.app.agents(
|
||||
{
|
||||
directory: this.options.defaultWorkspace
|
||||
},
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
const response = signal
|
||||
? await awaitWithAbort(operation, signal)
|
||||
: await operation
|
||||
if (response.error || !response.data) {
|
||||
throw new Error('OpenCode Agent 清单不可用')
|
||||
}
|
||||
@@ -1294,10 +1352,17 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (!controlled) {
|
||||
try {
|
||||
const client = await this.getClient()
|
||||
const response = await client.session.list({
|
||||
directory: this.options.defaultWorkspace,
|
||||
limit: 1
|
||||
})
|
||||
const response = await this.controlRequest(
|
||||
'连接检查',
|
||||
(signal) =>
|
||||
client.session.list(
|
||||
{
|
||||
directory: this.options.defaultWorkspace,
|
||||
limit: 1
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
if (response.error || !response.data) {
|
||||
throw new Error('connection check failed')
|
||||
}
|
||||
@@ -1337,18 +1402,40 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
skillsResult,
|
||||
resourcesResult
|
||||
] = await Promise.allSettled([
|
||||
client.session.list({
|
||||
directory,
|
||||
limit: 1
|
||||
}),
|
||||
this.controlRequest('连接检查', (signal) =>
|
||||
client.session.list(
|
||||
{
|
||||
directory,
|
||||
limit: 1
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
),
|
||||
this.discoverAgents(client),
|
||||
client.tool.ids({ directory }),
|
||||
client.command.list({ directory }),
|
||||
client.lsp.status({ directory }),
|
||||
client.formatter.status({ directory }),
|
||||
client.mcp.status({ directory }),
|
||||
client.app.skills({ directory }),
|
||||
client.experimental.resource.list({ directory })
|
||||
this.controlRequest('读取工具清单', (signal) =>
|
||||
client.tool.ids({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取命令清单', (signal) =>
|
||||
client.command.list({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取 LSP 状态', (signal) =>
|
||||
client.lsp.status({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取格式化器状态', (signal) =>
|
||||
client.formatter.status({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取 MCP 状态', (signal) =>
|
||||
client.mcp.status({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取技能清单', (signal) =>
|
||||
client.app.skills({ directory }, { signal })
|
||||
),
|
||||
this.controlRequest('读取资源清单', (signal) =>
|
||||
client.experimental.resource.list(
|
||||
{ directory },
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
])
|
||||
if (
|
||||
availabilityResult.status === 'rejected' ||
|
||||
@@ -1640,16 +1727,34 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
created: false
|
||||
}
|
||||
}
|
||||
const creation: Promise<string> = client.session
|
||||
.create(
|
||||
{
|
||||
title: 'GoodBuddy 对话',
|
||||
directory,
|
||||
...(agent ? { agent } : {}),
|
||||
...(permission ? { permission } : {})
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
const creation: Promise<string> = this.controlRequest(
|
||||
'创建会话',
|
||||
(controlSignal) =>
|
||||
client.session.create(
|
||||
{
|
||||
title: 'GoodBuddy 对话',
|
||||
directory,
|
||||
...(agent ? { agent } : {}),
|
||||
...(permission ? { permission } : {})
|
||||
},
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal,
|
||||
async (response) => {
|
||||
const sessionId = response.data?.id
|
||||
if (sessionId) {
|
||||
await client.session
|
||||
.delete(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory
|
||||
},
|
||||
{ signal: AbortSignal.timeout(1_000) }
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
if (!response.data) {
|
||||
throw new Error('OpenCode 会话创建失败')
|
||||
@@ -1698,23 +1803,29 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
const deadline = new AbortController()
|
||||
const deadlineTimer = setTimeout(
|
||||
() =>
|
||||
deadline.abort(
|
||||
new Error(
|
||||
`OpenCode 执行超过 ${executionDeadlineLabel(
|
||||
this.dependencies.executionTimeoutMs
|
||||
)}总时限`
|
||||
const configuredTimeout = this.dependencies.executionTimeoutMs
|
||||
const deadline =
|
||||
configuredTimeout === undefined
|
||||
? undefined
|
||||
: new AbortController()
|
||||
const deadlineTimer =
|
||||
configuredTimeout === undefined || deadline === undefined
|
||||
? undefined
|
||||
: setTimeout(
|
||||
() =>
|
||||
deadline.abort(
|
||||
new Error(
|
||||
`OpenCode 执行超过 ${executionDeadlineLabel(
|
||||
configuredTimeout
|
||||
)}总时限`
|
||||
)
|
||||
),
|
||||
configuredTimeout
|
||||
)
|
||||
),
|
||||
this.dependencies.executionTimeoutMs
|
||||
)
|
||||
deadlineTimer.unref?.()
|
||||
const executionSignal = AbortSignal.any([
|
||||
signal,
|
||||
deadline.signal
|
||||
])
|
||||
deadlineTimer?.unref?.()
|
||||
const executionSignal = deadline
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: signal
|
||||
let releaseEmbedded: (() => void) | undefined
|
||||
let releaseConversation: (() => void) | undefined
|
||||
try {
|
||||
@@ -1727,12 +1838,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
)
|
||||
yield* this.runUnlocked(request, executionSignal)
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted && !signal.aborted) {
|
||||
if (deadline?.signal.aborted && !signal.aborted) {
|
||||
throw deadline.signal.reason
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(deadlineTimer)
|
||||
if (deadlineTimer) {
|
||||
clearTimeout(deadlineTimer)
|
||||
}
|
||||
releaseConversation?.()
|
||||
releaseEmbedded?.()
|
||||
}
|
||||
@@ -1776,8 +1889,13 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
| undefined
|
||||
if (runtimeControl?.command) {
|
||||
const commandResponse = await awaitWithAbort(
|
||||
client.command.list({ directory }, { signal }),
|
||||
const commandResponse = await this.controlRequest(
|
||||
'验证原生命令',
|
||||
(controlSignal) =>
|
||||
client.command.list(
|
||||
{ directory },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
if (commandResponse.error || !commandResponse.data) {
|
||||
@@ -1817,18 +1935,22 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
this.usesEmbeddedPermissionMediation() &&
|
||||
this.options.knowledgeGateway?.getEndpoint()
|
||||
) {
|
||||
const knowledgeEndpoint =
|
||||
this.options.knowledgeGateway.getEndpoint()!
|
||||
knowledgeMcpName = `goodbuddy-data-${createHash('sha256')
|
||||
.update(`${request.conversationId}\0${request.requestId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 20)}`
|
||||
const added = await awaitWithAbort(
|
||||
client.mcp.add(
|
||||
const added = await this.controlRequest(
|
||||
'连接内置只读工具',
|
||||
(controlSignal) =>
|
||||
client.mcp.add(
|
||||
{
|
||||
directory,
|
||||
name: knowledgeMcpName,
|
||||
config: {
|
||||
type: 'remote',
|
||||
url: this.options.knowledgeGateway.getEndpoint()!,
|
||||
url: knowledgeEndpoint,
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
|
||||
@@ -1836,7 +1958,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
oauth: false
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
@@ -1863,6 +1985,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
this.options.knowledgeGateway?.getEndpoint() &&
|
||||
this.options.mcpServers?.length
|
||||
) {
|
||||
const customMcpEndpoint =
|
||||
this.options.knowledgeGateway.getEndpoint()!
|
||||
customMcpToken = this.options.knowledgeGateway.grantCustomMcp(
|
||||
request.requestId,
|
||||
this.options.mcpServers,
|
||||
@@ -1878,14 +2002,16 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
.update(`${request.conversationId}\0${request.requestId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 20)}`
|
||||
const added = await awaitWithAbort(
|
||||
client.mcp.add(
|
||||
const added = await this.controlRequest(
|
||||
'连接自定义 MCP 工具',
|
||||
(controlSignal) =>
|
||||
client.mcp.add(
|
||||
{
|
||||
directory,
|
||||
name: customMcpName,
|
||||
config: {
|
||||
type: 'remote',
|
||||
url: this.options.knowledgeGateway.getEndpoint()!,
|
||||
url: customMcpEndpoint,
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${customMcpToken}`
|
||||
@@ -1893,7 +2019,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
oauth: false
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
@@ -1942,8 +2068,13 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
]
|
||||
let disabledTools: Record<string, boolean> | undefined
|
||||
if (request.workMode !== 'execute') {
|
||||
const tools = await awaitWithAbort(
|
||||
client.tool.ids({ directory }, { signal }),
|
||||
const tools = await this.controlRequest(
|
||||
'读取工具清单',
|
||||
(controlSignal) =>
|
||||
client.tool.ids(
|
||||
{ directory },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
if (tools.error || !tools.data) {
|
||||
@@ -1969,14 +2100,16 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
)
|
||||
const sessionId = session.id
|
||||
if (!session.created) {
|
||||
const update = await awaitWithAbort(
|
||||
client.session.update(
|
||||
const update = await this.controlRequest(
|
||||
'更新会话权限',
|
||||
(controlSignal) =>
|
||||
client.session.update(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
permission
|
||||
},
|
||||
{ signal }
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
@@ -1991,8 +2124,13 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
message: 'OpenCode 正在处理请求'
|
||||
}
|
||||
|
||||
const subscription = await awaitWithAbort(
|
||||
client.event.subscribe({ directory }, { signal }),
|
||||
const subscription = await this.controlRequest(
|
||||
'订阅事件流',
|
||||
(controlSignal) =>
|
||||
client.event.subscribe(
|
||||
{ directory },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
|
||||
@@ -2012,6 +2150,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
input?: string
|
||||
output?: string
|
||||
error?: string
|
||||
subagent?: SubagentEvent
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
@@ -2063,30 +2202,39 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
: client.session.promptAsync(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
model: this.options.modelProfile
|
||||
? {
|
||||
providerID: resolveOpenCodeProvider(
|
||||
this.options.modelProfile
|
||||
).id,
|
||||
modelID: this.options.modelProfile.modelName
|
||||
}
|
||||
: undefined,
|
||||
...(selectedAgent ? { agent: selectedAgent } : {}),
|
||||
system:
|
||||
nativeSkillIds.length > 0
|
||||
? undefined
|
||||
: this.options.skillInstructions || undefined,
|
||||
...(disabledTools ? { tools: disabledTools } : {}),
|
||||
parts: [
|
||||
{ type: 'text' as const, text: promptText },
|
||||
...imageParts
|
||||
]
|
||||
},
|
||||
{ signal }
|
||||
: this.controlRequest(
|
||||
'提交请求',
|
||||
(controlSignal) =>
|
||||
client.session.promptAsync(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
model: this.options.modelProfile
|
||||
? {
|
||||
providerID: resolveOpenCodeProvider(
|
||||
this.options.modelProfile
|
||||
).id,
|
||||
modelID: this.options.modelProfile.modelName
|
||||
}
|
||||
: undefined,
|
||||
...(selectedAgent
|
||||
? { agent: selectedAgent }
|
||||
: {}),
|
||||
system:
|
||||
nativeSkillIds.length > 0
|
||||
? undefined
|
||||
: this.options.skillInstructions || undefined,
|
||||
...(disabledTools
|
||||
? { tools: disabledTools }
|
||||
: {}),
|
||||
parts: [
|
||||
{ type: 'text' as const, text: promptText },
|
||||
...imageParts
|
||||
]
|
||||
},
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
prompt.catch(() => undefined)
|
||||
|
||||
@@ -2178,23 +2326,39 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
typeof part.state.output === 'string'
|
||||
? part.state.output.slice(0, 16_000)
|
||||
: undefined
|
||||
const subagent =
|
||||
toolName.trim().toLowerCase() === 'task'
|
||||
? toOpenCodeSubagentEvent({
|
||||
requestId: request.requestId,
|
||||
callId,
|
||||
state: part.state.status,
|
||||
input: part.state.input,
|
||||
output,
|
||||
error
|
||||
})
|
||||
: undefined
|
||||
toolStates.set(callId, {
|
||||
name: toolName,
|
||||
state,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
...(error ? { error } : {}),
|
||||
...(subagent ? { subagent } : {})
|
||||
})
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId,
|
||||
name: toolName,
|
||||
state,
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
if (subagent) {
|
||||
yield subagent
|
||||
} else {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId,
|
||||
name: toolName,
|
||||
state,
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2309,12 +2473,21 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
const permissionId = properties.id
|
||||
repliedPermissionIds.add(properties.id)
|
||||
const rejection = await client.permission.reply({
|
||||
requestID: properties.id,
|
||||
directory,
|
||||
reply: 'reject'
|
||||
})
|
||||
const rejection = await this.controlRequest(
|
||||
'回复权限请求',
|
||||
(controlSignal) =>
|
||||
client.permission.reply(
|
||||
{
|
||||
requestID: permissionId,
|
||||
directory,
|
||||
reply: 'reject'
|
||||
},
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
if (rejection.error || rejection.data !== true) {
|
||||
throw new Error('OpenCode 权限拒绝回复失败', {
|
||||
cause: error
|
||||
@@ -2355,14 +2528,23 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const allowKnowledge =
|
||||
request.workMode === 'ask' &&
|
||||
knowledgeToolIds.includes(permissionRequest.permission)
|
||||
const response = await client.permission.reply({
|
||||
requestID: permissionRequest.id,
|
||||
directory,
|
||||
reply:
|
||||
request.workMode === 'execute' || allowKnowledge
|
||||
? 'once'
|
||||
: 'reject'
|
||||
})
|
||||
const response = await this.controlRequest(
|
||||
'回复权限请求',
|
||||
(controlSignal) =>
|
||||
client.permission.reply(
|
||||
{
|
||||
requestID: permissionRequest.id,
|
||||
directory,
|
||||
reply:
|
||||
request.workMode === 'execute' ||
|
||||
allowKnowledge
|
||||
? 'once'
|
||||
: 'reject'
|
||||
},
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
if (response.error || response.data !== true) {
|
||||
throw new Error('OpenCode 权限回复失败')
|
||||
}
|
||||
@@ -2414,6 +2596,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
for (const [callId, tool] of failedTools) {
|
||||
if (tool.subagent) {
|
||||
continue
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
@@ -2449,16 +2634,30 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
abortSession()
|
||||
for (const [callId, tool] of toolStates) {
|
||||
if (tool.state === 'pending' || tool.state === 'running') {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId,
|
||||
name: tool.name,
|
||||
state: 'failed',
|
||||
summary: `OpenCode 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
if (tool.subagent) {
|
||||
yield {
|
||||
...tool.subagent,
|
||||
state: signal.aborted ? 'cancelled' : 'failed',
|
||||
...(signal.aborted
|
||||
? { reason: tool.subagent.reason ?? '父请求已取消' }
|
||||
: {
|
||||
error:
|
||||
tool.subagent.error ??
|
||||
'OpenCode 子 Agent 未返回完成状态'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId,
|
||||
name: tool.name,
|
||||
state: 'failed',
|
||||
summary: `OpenCode 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2505,16 +2704,30 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
const response = answers
|
||||
? answers.length === pending.questionCount
|
||||
? await pending.client.question.reply({
|
||||
requestID: pending.upstreamQuestionId,
|
||||
directory: pending.directory,
|
||||
answers
|
||||
})
|
||||
? await this.controlRequest(
|
||||
'提交提问回答',
|
||||
(signal) =>
|
||||
pending.client.question.reply(
|
||||
{
|
||||
requestID: pending.upstreamQuestionId,
|
||||
directory: pending.directory,
|
||||
answers
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
: await pending.client.question.reject({
|
||||
requestID: pending.upstreamQuestionId,
|
||||
directory: pending.directory
|
||||
})
|
||||
: await this.controlRequest(
|
||||
'取消提问',
|
||||
(signal) =>
|
||||
pending.client.question.reject(
|
||||
{
|
||||
requestID: pending.upstreamQuestionId,
|
||||
directory: pending.directory
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
if (!response) {
|
||||
throw new Error('OpenCode 提问回答数量不匹配')
|
||||
}
|
||||
@@ -2536,17 +2749,25 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
'外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact'
|
||||
)
|
||||
}
|
||||
const deadline = new AbortController()
|
||||
const deadlineTimer = setTimeout(
|
||||
() =>
|
||||
deadline.abort(new Error('OpenCode 原生 Compact 执行超时')),
|
||||
this.dependencies.executionTimeoutMs
|
||||
)
|
||||
deadlineTimer.unref?.()
|
||||
const executionSignal = AbortSignal.any([
|
||||
signal,
|
||||
deadline.signal
|
||||
])
|
||||
const configuredTimeout = this.dependencies.executionTimeoutMs
|
||||
const deadline =
|
||||
configuredTimeout === undefined
|
||||
? undefined
|
||||
: new AbortController()
|
||||
const deadlineTimer =
|
||||
configuredTimeout === undefined || deadline === undefined
|
||||
? undefined
|
||||
: setTimeout(
|
||||
() =>
|
||||
deadline.abort(
|
||||
new Error('OpenCode 原生 Compact 执行超时')
|
||||
),
|
||||
configuredTimeout
|
||||
)
|
||||
deadlineTimer?.unref?.()
|
||||
const executionSignal = deadline
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: signal
|
||||
let releaseEmbedded: (() => void) | undefined
|
||||
let releaseConversation: (() => void) | undefined
|
||||
try {
|
||||
@@ -2567,9 +2788,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
const client = await this.getClient(executionSignal)
|
||||
const context = await client.v2.session.context(
|
||||
{ sessionID: sessionId },
|
||||
{ signal: executionSignal }
|
||||
const context = await this.controlRequest(
|
||||
'读取原生上下文',
|
||||
(controlSignal) =>
|
||||
client.v2.session.context(
|
||||
{ sessionID: sessionId },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
executionSignal
|
||||
)
|
||||
if (context.error || !context.data) {
|
||||
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
|
||||
@@ -2602,14 +2828,18 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
executionSignal.throwIfAborted()
|
||||
const subscriptionController = new AbortController()
|
||||
const subscription = await client.event.subscribe(
|
||||
{ directory: this.options.defaultWorkspace },
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
executionSignal,
|
||||
subscriptionController.signal
|
||||
])
|
||||
}
|
||||
const subscriptionSignal = AbortSignal.any([
|
||||
executionSignal,
|
||||
subscriptionController.signal
|
||||
])
|
||||
const subscription = await this.controlRequest(
|
||||
'订阅 Compact 事件流',
|
||||
(controlSignal) =>
|
||||
client.event.subscribe(
|
||||
{ directory: this.options.defaultWorkspace },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
subscriptionSignal
|
||||
)
|
||||
const usageEvents: RuntimeModelUsageEvent[] = []
|
||||
const reportedMessageIds = new Set<string>()
|
||||
@@ -2689,12 +2919,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
await usageCapture.catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted && !signal.aborted) {
|
||||
if (deadline?.signal.aborted && !signal.aborted) {
|
||||
throw deadline.signal.reason
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(deadlineTimer)
|
||||
if (deadlineTimer) {
|
||||
clearTimeout(deadlineTimer)
|
||||
}
|
||||
releaseConversation?.()
|
||||
releaseEmbedded?.()
|
||||
}
|
||||
@@ -2724,11 +2956,17 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (!sessionId || !this.client) {
|
||||
return
|
||||
}
|
||||
await this.client.session
|
||||
.delete({
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace
|
||||
})
|
||||
await this.controlRequest(
|
||||
'释放会话',
|
||||
(signal) =>
|
||||
this.client!.session.delete(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
|
||||
describe('OpenCode native subagent events', () => {
|
||||
it('maps Task tool updates to stable bounded subagent identities', () => {
|
||||
const running = toOpenCodeSubagentEvent({
|
||||
requestId,
|
||||
callId: 'call-task-1',
|
||||
state: 'running',
|
||||
input: {
|
||||
subagent_type: 'explorer',
|
||||
description: 'Review application architecture',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
}
|
||||
})
|
||||
const completed = toOpenCodeSubagentEvent({
|
||||
requestId,
|
||||
callId: 'call-task-1',
|
||||
state: 'completed',
|
||||
input: {
|
||||
subagent_type: 'explorer',
|
||||
description: 'Review application architecture',
|
||||
prompt: 'Inspect the complete source tree.'
|
||||
},
|
||||
output: 'Architecture review complete.'
|
||||
})
|
||||
|
||||
expect(running).toMatchObject({
|
||||
type: 'subagent',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'running',
|
||||
reason: 'Review application architecture'
|
||||
})
|
||||
expect(completed).toMatchObject({
|
||||
childTaskId: running?.childTaskId,
|
||||
expertId: running?.expertId,
|
||||
state: 'completed',
|
||||
output: 'Architecture review complete.'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not classify unrelated tools as subagents', () => {
|
||||
expect(
|
||||
toOpenCodeSubagentEvent({
|
||||
requestId,
|
||||
callId: 'call-read-1',
|
||||
state: 'completed',
|
||||
input: { filePath: 'README.md' },
|
||||
output: 'contents'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { boundedToolDetail } from './approval-summary'
|
||||
|
||||
type OpenCodeSubagentState =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'error'
|
||||
|
||||
export type OpenCodeSubagentToolUpdate = {
|
||||
requestId: string
|
||||
callId: string
|
||||
state: OpenCodeSubagentState
|
||||
input: unknown
|
||||
output?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export type OpenCodeSubagentInput = {
|
||||
subagent_type: string
|
||||
prompt: string
|
||||
description?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
)
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
maximum: number
|
||||
): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const normalized = value.trim()
|
||||
return normalized ? normalized.slice(0, maximum) : undefined
|
||||
}
|
||||
|
||||
function boundedDetail(
|
||||
value: unknown,
|
||||
maximum: number
|
||||
): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return boundedText(value, maximum)
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
return boundedToolDetail(value, maximum)
|
||||
}
|
||||
|
||||
function deterministicUuid(value: string): string {
|
||||
const bytes = createHash('sha256').update(value).digest().subarray(0, 16)
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x50
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||
const hex = bytes.toString('hex')
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20)
|
||||
].join('-')
|
||||
}
|
||||
|
||||
export function parseOpenCodeSubagentInput(
|
||||
value: unknown
|
||||
): OpenCodeSubagentInput | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const subagentType = boundedText(value.subagent_type, 80)
|
||||
const prompt = boundedText(value.prompt, 20_000)
|
||||
if (!subagentType || !prompt) {
|
||||
return undefined
|
||||
}
|
||||
const description = boundedText(value.description, 240)
|
||||
const command = boundedText(value.command, 240)
|
||||
return {
|
||||
subagent_type: subagentType,
|
||||
prompt,
|
||||
...(description ? { description } : {}),
|
||||
...(command ? { command } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function toOpenCodeSubagentEvent(
|
||||
update: OpenCodeSubagentToolUpdate
|
||||
): SubagentEvent | undefined {
|
||||
const input = parseOpenCodeSubagentInput(update.input)
|
||||
if (!input) {
|
||||
return undefined
|
||||
}
|
||||
const expertName = input.subagent_type
|
||||
const description =
|
||||
input.description ?? input.command
|
||||
const state =
|
||||
update.state === 'pending'
|
||||
? 'queued'
|
||||
: update.state === 'running' ||
|
||||
update.state === 'in_progress'
|
||||
? 'running'
|
||||
: update.state === 'completed'
|
||||
? 'completed'
|
||||
: 'failed'
|
||||
const failed = state === 'failed'
|
||||
const output = failed
|
||||
? undefined
|
||||
: boundedDetail(update.output, 16_000)
|
||||
const error = failed
|
||||
? boundedDetail(update.error ?? update.output, 1_000)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
requestId: update.requestId,
|
||||
type: 'subagent',
|
||||
childTaskId: deterministicUuid(
|
||||
`opencode-subagent-call\0${update.requestId}\0${update.callId}`
|
||||
),
|
||||
expertId: deterministicUuid(
|
||||
`opencode-subagent-type\0${expertName}`
|
||||
),
|
||||
expertName,
|
||||
routingMode: 'native',
|
||||
runtimeCallId: update.callId.slice(0, 256),
|
||||
state,
|
||||
...(description ? { reason: description } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
@@ -3308,6 +3308,20 @@ describe('AssistantDatabase', () => {
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000513',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000514',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native' as const,
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'completed' as const,
|
||||
reason: 'Review application architecture',
|
||||
output: 'Architecture review complete.'
|
||||
}
|
||||
],
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed' as const,
|
||||
@@ -3368,6 +3382,20 @@ describe('AssistantDatabase', () => {
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000513',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000514',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'completed',
|
||||
reason: 'Review application architecture',
|
||||
output: 'Architecture review complete.'
|
||||
}
|
||||
],
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed',
|
||||
@@ -4278,7 +4306,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('durably interrupts active tool metadata during startup recovery', async () => {
|
||||
it('durably interrupts active tool and subagent metadata during startup recovery', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-conversation-recovery-')
|
||||
)
|
||||
@@ -4289,6 +4317,8 @@ describe('AssistantDatabase', () => {
|
||||
const messageId = '00000000-0000-4000-8000-000000000218'
|
||||
const cancelledMessageId =
|
||||
'00000000-0000-4000-8000-000000000219'
|
||||
const subagentMessageId =
|
||||
'00000000-0000-4000-8000-000000000220'
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
initial.replaceConversations([
|
||||
@@ -4342,6 +4372,38 @@ describe('AssistantDatabase', () => {
|
||||
error: 'runtime parser detail'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: subagentMessageId,
|
||||
role: 'assistant',
|
||||
content: '父请求已完成',
|
||||
createdAt: 1_775_000_003_000,
|
||||
state: 'complete',
|
||||
status: '已完成',
|
||||
subagents: [
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000221',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000222',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'task-1',
|
||||
state: 'running',
|
||||
reason: '检查项目'
|
||||
},
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000223',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000224',
|
||||
expertName: 'worker',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'task-2',
|
||||
state: 'completed',
|
||||
output: '已完成'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4370,6 +4432,21 @@ describe('AssistantDatabase', () => {
|
||||
expect.objectContaining({ name: 'failed-tool', state: 'failed' })
|
||||
]
|
||||
})
|
||||
expect(recovered.listConversations()[0]?.messages[2]).toMatchObject({
|
||||
id: subagentMessageId,
|
||||
state: 'complete',
|
||||
status: '已完成',
|
||||
subagents: [
|
||||
expect.objectContaining({
|
||||
runtimeCallId: 'task-1',
|
||||
state: 'cancelled'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runtimeCallId: 'task-2',
|
||||
state: 'completed'
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(recovered.listConversations()[0]?.messages[1]).toMatchObject({
|
||||
id: cancelledMessageId,
|
||||
state: 'error',
|
||||
|
||||
@@ -215,6 +215,7 @@ type MessageMetadata = {
|
||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
||||
@@ -903,6 +904,16 @@ function interruptActiveTools(
|
||||
)
|
||||
}
|
||||
|
||||
function interruptActiveSubagents(
|
||||
subagents: MessageMetadata['subagents']
|
||||
): MessageMetadata['subagents'] {
|
||||
return subagents?.map((subagent) =>
|
||||
subagent.state === 'queued' || subagent.state === 'running'
|
||||
? { ...subagent, state: 'cancelled' as const }
|
||||
: subagent
|
||||
)
|
||||
}
|
||||
|
||||
function interruptActiveToolBlocks(
|
||||
blocks: MessageMetadata['blocks']
|
||||
): MessageMetadata['blocks'] {
|
||||
@@ -1017,6 +1028,9 @@ function toConversationSnapshot(
|
||||
tools: interrupted
|
||||
? interruptActiveTools(metadata.tools)
|
||||
: metadata.tools,
|
||||
subagents: interrupted
|
||||
? interruptActiveSubagents(metadata.subagents)
|
||||
: metadata.subagents,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -1040,6 +1054,7 @@ function serializeConversationMessageMetadata(
|
||||
contextCompression: message.contextCompression,
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -1417,7 +1432,7 @@ export class AssistantDatabase {
|
||||
const metadata = JSON.parse(
|
||||
message.metadata_json
|
||||
) as MessageMetadata
|
||||
const hasActiveTool = Boolean(
|
||||
const hasActiveActivity = Boolean(
|
||||
metadata.tools?.some(
|
||||
(tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running'
|
||||
@@ -1427,9 +1442,17 @@ export class AssistantDatabase {
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' ||
|
||||
block.tool.state === 'running')
|
||||
) ||
|
||||
metadata.subagents?.some(
|
||||
(subagent) =>
|
||||
subagent.state === 'queued' ||
|
||||
subagent.state === 'running'
|
||||
)
|
||||
)
|
||||
if (message.state !== 'streaming' && !hasActiveTool) {
|
||||
if (
|
||||
message.state !== 'streaming' &&
|
||||
!hasActiveActivity
|
||||
) {
|
||||
continue
|
||||
}
|
||||
updateMessage.run(
|
||||
@@ -1441,7 +1464,10 @@ export class AssistantDatabase {
|
||||
? interruptedMessageStatus
|
||||
: metadata.status,
|
||||
tools: interruptActiveTools(metadata.tools),
|
||||
blocks: interruptActiveToolBlocks(metadata.blocks)
|
||||
blocks: interruptActiveToolBlocks(metadata.blocks),
|
||||
subagents: interruptActiveSubagents(
|
||||
metadata.subagents
|
||||
)
|
||||
}),
|
||||
message.id
|
||||
)
|
||||
|
||||
@@ -6706,6 +6706,62 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('does not fail a completed native subagent for its provisional Task tool', async () => {
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
async *run(request: { requestId: string }) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: 'call-task-1',
|
||||
name: 'task',
|
||||
state: 'running',
|
||||
summary: 'OpenCode 工具:task'
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'subagent',
|
||||
childTaskId: '00000000-0000-4000-8000-000000000601',
|
||||
expertId: '00000000-0000-4000-8000-000000000701',
|
||||
expertName: 'explore',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'completed',
|
||||
output: 'Desktop files inspected.'
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'inspect the desktop',
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'completed'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).not.toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'failed',
|
||||
expect.any(String)
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['opencode', 'continue'] as const)(
|
||||
'preserves read-only Ask mode at the %s Runtime boundary',
|
||||
async (runtimeId) => {
|
||||
|
||||
@@ -3488,6 +3488,13 @@ export function registerIpcHandlers(
|
||||
if (publicEvent.type === 'tool') {
|
||||
toolStates.set(publicEvent.callId, publicEvent)
|
||||
}
|
||||
if (
|
||||
publicEvent.type === 'subagent' &&
|
||||
publicEvent.routingMode === 'native' &&
|
||||
publicEvent.runtimeCallId
|
||||
) {
|
||||
toolStates.delete(publicEvent.runtimeCallId)
|
||||
}
|
||||
if (publicEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(publicEvent.questionId, {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -88,6 +88,16 @@ describe('ReleaseNotesService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not present older notes as a missing current release', async () => {
|
||||
const { service, settingsStore } = await createService('0.8.19')
|
||||
await settingsStore.setLastSeenReleaseNotesVersion('0.8.11')
|
||||
|
||||
await expect(service.getPending()).resolves.toEqual({
|
||||
currentVersion: '0.8.19',
|
||||
releases: []
|
||||
})
|
||||
})
|
||||
|
||||
it('persists acknowledgement and does not show the release again', async () => {
|
||||
const { service, settingsStore } = await createService('0.8.18')
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ export class ReleaseNotesService {
|
||||
async getPending(): Promise<ReleaseNotesSnapshot> {
|
||||
const releases = await this.loadReleases()
|
||||
const currentVersion = this.dependencies.currentVersion
|
||||
if (!releases.some((release) => release.version === currentVersion)) {
|
||||
return {
|
||||
currentVersion,
|
||||
releases: []
|
||||
}
|
||||
}
|
||||
const lastSeenVersion =
|
||||
await this.dependencies.settingsStore.getLastSeenReleaseNotesVersion()
|
||||
const pending = releases.filter((release) => {
|
||||
|
||||
@@ -11,8 +11,15 @@ describe('packaged release notes', () => {
|
||||
'utf8'
|
||||
)
|
||||
) as unknown
|
||||
const packageSource = JSON.parse(
|
||||
await readFile(join(process.cwd(), 'package.json'), 'utf8')
|
||||
) as { version?: unknown }
|
||||
const parsed = releaseNotesFileSchema.parse(source)
|
||||
|
||||
expect(typeof packageSource.version).toBe('string')
|
||||
expect(parsed.releases).toContainEqual(
|
||||
expect.objectContaining({ version: packageSource.version })
|
||||
)
|
||||
expect(parsed.releases).toContainEqual(
|
||||
expect.objectContaining({ version: '0.8.19' })
|
||||
)
|
||||
|
||||
@@ -22,7 +22,8 @@ import { AgentFrameError } from '../../shared/agent-protocol/frame'
|
||||
import {
|
||||
assertRemotePromptAcceptanceMatchesPreparation,
|
||||
remotePromptOperationAcceptanceSchema,
|
||||
remotePromptOperationPreparationSchema
|
||||
remotePromptOperationPreparationSchema,
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
modelBridgePolicySchema
|
||||
@@ -66,6 +67,9 @@ const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
||||
const DEFAULT_CONTROL_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32
|
||||
const UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS = Date.parse(
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||
)
|
||||
const MAXIMUM_PENDING_OUTPUT_WRITES = 256
|
||||
const MAXIMUM_OUTPUT_WRITE_BYTES =
|
||||
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
|
||||
@@ -1038,11 +1042,16 @@ export class ProtocolRemoteRuntimeChannel
|
||||
].some(
|
||||
(bridge) => bridge.providerDeliveryMayBeUncertain
|
||||
)
|
||||
const recoveryDeadlineExpired =
|
||||
this.#recoveryDeadlineAt !== undefined &&
|
||||
this.#recoveryDeadlineAt !==
|
||||
UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS &&
|
||||
Date.now() >= this.#recoveryDeadlineAt
|
||||
if (
|
||||
this.#recoveryDeadlineAt === undefined ||
|
||||
this.#recoverySignal === undefined ||
|
||||
this.#recoverySignal.aborted ||
|
||||
Date.now() >= this.#recoveryDeadlineAt ||
|
||||
recoveryDeadlineExpired ||
|
||||
this.#activeBinarySends > 0 ||
|
||||
this.#pendingControlRequests > 0 ||
|
||||
providerDeliveryMayBeUncertain ||
|
||||
@@ -1090,11 +1099,13 @@ export class ProtocolRemoteRuntimeChannel
|
||||
await this.#flushRecoveryPersistence()
|
||||
const deadlineAt = this.#recoveryDeadlineAt!
|
||||
const callerSignal = this.#recoverySignal!
|
||||
const remaining = deadlineAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
if (
|
||||
deadlineAt !== UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS &&
|
||||
Date.now() >= deadlineAt
|
||||
) {
|
||||
throw new ProtocolRemoteRuntimeChannelError(
|
||||
'Remote Runtime recovery deadline expired',
|
||||
'aborted'
|
||||
'transport'
|
||||
)
|
||||
}
|
||||
const recoveryController = new AbortController()
|
||||
@@ -1108,6 +1119,13 @@ export class ProtocolRemoteRuntimeChannel
|
||||
)
|
||||
}
|
||||
callerSignal.addEventListener('abort', abort, { once: true })
|
||||
const recoveryTimeoutMs =
|
||||
deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS
|
||||
? this.#state.controlTimeoutMs
|
||||
: Math.min(
|
||||
this.#state.controlTimeoutMs,
|
||||
Math.max(0, deadlineAt - Date.now())
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
recoveryController.abort(
|
||||
new DOMException(
|
||||
@@ -1115,7 +1133,7 @@ export class ProtocolRemoteRuntimeChannel
|
||||
'TimeoutError'
|
||||
)
|
||||
)
|
||||
}, remaining)
|
||||
}, recoveryTimeoutMs)
|
||||
try {
|
||||
await this.#state.connection.reconnect!(
|
||||
recoveryController.signal
|
||||
@@ -1151,10 +1169,7 @@ export class ProtocolRemoteRuntimeChannel
|
||||
channelId: this.#state.openResult.channelId,
|
||||
channelEpoch: this.#state.openResult.channelEpoch
|
||||
},
|
||||
Math.min(
|
||||
this.#state.controlTimeoutMs,
|
||||
Math.max(1, deadlineAt - Date.now())
|
||||
),
|
||||
this.#state.controlTimeoutMs,
|
||||
recoveryController.signal
|
||||
),
|
||||
'Remote Runtime resume response is invalid'
|
||||
@@ -1205,10 +1220,7 @@ export class ProtocolRemoteRuntimeChannel
|
||||
acknowledgedSequence:
|
||||
resume.cursors.lastMainAckSequence
|
||||
},
|
||||
Math.min(
|
||||
this.#state.controlTimeoutMs,
|
||||
Math.max(1, deadlineAt - Date.now())
|
||||
),
|
||||
this.#state.controlTimeoutMs,
|
||||
recoveryController.signal
|
||||
),
|
||||
'Remote Runtime replay response is invalid'
|
||||
|
||||
@@ -8515,7 +8515,7 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows bounded Subagent states and records child expert activity', async () => {
|
||||
it('shows Subagent states and records child expert activity', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '分析复杂问题' }
|
||||
@@ -8568,19 +8568,26 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const statusRegion = await screen.findByLabelText('子专家状态')
|
||||
const statusRegion = await screen.findByLabelText('子代理状态')
|
||||
expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('等待中')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('进行中')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('失败')).toBeInTheDocument()
|
||||
const subagentCards = within(statusRegion).getAllByRole('group')
|
||||
fireEvent.click(
|
||||
within(subagentCards[2]!).getByText('安全专家', {
|
||||
selector: 'strong'
|
||||
})
|
||||
)
|
||||
fireEvent(subagentCards[2]!, new Event('toggle'))
|
||||
expect(
|
||||
within(statusRegion).getByText('无法读取必要上下文')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(statusRegion).queryByText('第四位专家')
|
||||
).not.toBeInTheDocument()
|
||||
within(statusRegion).getByText('第四位专家')
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
@@ -8599,9 +8606,20 @@ describe('App', () => {
|
||||
output: '代码专家的部分结论'
|
||||
})
|
||||
})
|
||||
expect(within(statusRegion).getByText('已完成')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getAllByText('已完成')).toHaveLength(2)
|
||||
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
|
||||
expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument()
|
||||
for (const [index, expertName] of [
|
||||
'研究专家',
|
||||
'代码专家'
|
||||
].entries()) {
|
||||
fireEvent.click(
|
||||
within(subagentCards[index]!).getByText(expertName, {
|
||||
selector: 'strong'
|
||||
})
|
||||
)
|
||||
fireEvent(subagentCards[index]!, new Event('toggle'))
|
||||
}
|
||||
expect(
|
||||
within(statusRegion).getByText('研究专家的独立结论')
|
||||
).toBeInTheDocument()
|
||||
@@ -8627,7 +8645,7 @@ describe('App', () => {
|
||||
fireEvent.click(screen.getByText('运行记录'))
|
||||
expect(await screen.findAllByText('子专家')).toHaveLength(4)
|
||||
expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/指定专家/u).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByText(/研究专家的独立结论/u)
|
||||
).toBeInTheDocument()
|
||||
@@ -8636,6 +8654,144 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces an incremental OpenCode Task tool with a native subagent card', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '检查架构' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: 'call-task-1',
|
||||
name: 'Review application architecture',
|
||||
state: 'running',
|
||||
summary: '远端 Runtime 工具:Review application architecture'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: '等待子代理结果'
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('等待子代理结果')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'subagent',
|
||||
childTaskId: '00000000-0000-4000-8000-000000000611',
|
||||
expertId: '00000000-0000-4000-8000-000000000711',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'running',
|
||||
reason: 'Review application architecture'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: '父 Agent 最终回复'
|
||||
})
|
||||
})
|
||||
|
||||
const region = await screen.findByLabelText('子代理状态')
|
||||
expect(within(region).getByText('explorer')).toBeInTheDocument()
|
||||
expect(within(region).getByText('OpenCode 原生'))
|
||||
.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', {
|
||||
name: '工具执行,共 1 项'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
const assistantArticle = screen
|
||||
.getByText('父 Agent 最终回复')
|
||||
.closest('article')
|
||||
const orderedBlocks = [
|
||||
...assistantArticle!.querySelectorAll('.message-blocks > *')
|
||||
].map((element) => element.textContent)
|
||||
expect(orderedBlocks).toEqual([
|
||||
expect.stringContaining('Review application architecture'),
|
||||
expect.stringContaining('等待子代理结果'),
|
||||
expect.stringContaining('父 Agent 最终回复')
|
||||
])
|
||||
fireEvent.click(screen.getByText('运行记录'))
|
||||
expect(await screen.findAllByText('子专家')).toHaveLength(1)
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'远端 Runtime 工具:Review application architecture'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fails an unterminated subagent when the parent reports done', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '检查未完成子任务' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'subagent',
|
||||
childTaskId: '00000000-0000-4000-8000-000000000612',
|
||||
expertId: '00000000-0000-4000-8000-000000000712',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-2',
|
||||
state: 'running',
|
||||
reason: '检查未完成路径'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
})
|
||||
})
|
||||
|
||||
const region = await screen.findByLabelText('子代理状态')
|
||||
expect(within(region).getByText('失败')).toBeInTheDocument()
|
||||
const subagentCard = within(region).getByRole('group')
|
||||
fireEvent.click(
|
||||
within(subagentCard).getByText('explorer', {
|
||||
selector: 'strong'
|
||||
})
|
||||
)
|
||||
fireEvent(subagentCard, new Event('toggle'))
|
||||
expect(
|
||||
within(region).getByText(
|
||||
'父请求已结束,但子 Agent 未报告完成状态。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
const persistedMessages = vi
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.flatMap(([batch]) =>
|
||||
batch.flatMap((conversation) => conversation.messages)
|
||||
)
|
||||
expect(
|
||||
persistedMessages.some(
|
||||
(message) =>
|
||||
message.subagents?.[0]?.state === 'failed' &&
|
||||
message.subagents[0].error ===
|
||||
'父请求已结束,但子 Agent 未报告完成状态。'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('offers once, session, permanent, and deny for a tool call', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
+161
-26
@@ -128,6 +128,7 @@ import {
|
||||
conversationMessageBlocksSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
interactiveWorkModes,
|
||||
maximumConversationSubagentActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -619,9 +620,57 @@ function upsertMessageToolBlock(
|
||||
]
|
||||
}
|
||||
|
||||
function upsertMessageSubagentBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
childTaskId: string,
|
||||
runtimeCallId?: string
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
if (!blocks) {
|
||||
return blocks
|
||||
}
|
||||
if (
|
||||
blocks.some(
|
||||
(block) =>
|
||||
block.type === 'subagent' &&
|
||||
block.childTaskId === childTaskId
|
||||
)
|
||||
) {
|
||||
return blocks
|
||||
}
|
||||
const provisionalIndex = runtimeCallId
|
||||
? blocks.findIndex(
|
||||
(block) =>
|
||||
block.type === 'tool' &&
|
||||
block.tool.callId === runtimeCallId
|
||||
)
|
||||
: -1
|
||||
if (provisionalIndex >= 0) {
|
||||
return blocks.map((block, index) =>
|
||||
index === provisionalIndex
|
||||
? {
|
||||
id: block.id,
|
||||
type: 'subagent' as const,
|
||||
childTaskId
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maxMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'subagent',
|
||||
childTaskId
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function terminalizeMessageToolBlocks(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
state: 'failed' | 'cancelled'
|
||||
state: 'failed' | 'cancelled' | 'interrupted'
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
return blocks?.map((block) =>
|
||||
block.type === 'tool' &&
|
||||
@@ -1212,7 +1261,8 @@ function isConversation(value: unknown): value is Conversation {
|
||||
))) &&
|
||||
(entry.subagents === undefined ||
|
||||
(Array.isArray(entry.subagents) &&
|
||||
entry.subagents.length <= 3 &&
|
||||
entry.subagents.length <=
|
||||
maximumConversationSubagentActivities &&
|
||||
entry.subagents.every(
|
||||
(subagent) =>
|
||||
conversationSubagentActivitySchema.safeParse(subagent)
|
||||
@@ -4183,7 +4233,10 @@ function App(): React.JSX.Element {
|
||||
conversationId: run.conversationId,
|
||||
parentTaskId: event.requestId,
|
||||
expertId: event.expertId,
|
||||
routingMode: event.routingMode,
|
||||
routingMode:
|
||||
event.routingMode === 'native'
|
||||
? undefined
|
||||
: event.routingMode,
|
||||
title: event.expertName,
|
||||
instructions:
|
||||
event.reason ??
|
||||
@@ -4205,8 +4258,23 @@ function App(): React.JSX.Element {
|
||||
? current.map((task) =>
|
||||
task.id === event.childTaskId ? childTask : task
|
||||
)
|
||||
: [...current, childTask].slice(0, 100)
|
||||
: [...current, childTask].slice(
|
||||
0,
|
||||
maximumConversationSubagentActivities
|
||||
)
|
||||
})
|
||||
if (event.runtimeCallId) {
|
||||
setActivityRecords((current) =>
|
||||
current.filter(
|
||||
(record) =>
|
||||
!(
|
||||
record.requestId === event.requestId &&
|
||||
record.kind === 'tool' &&
|
||||
record.callId === event.runtimeCallId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
recordActivity({
|
||||
conversationId: run.conversationId,
|
||||
requestId: event.requestId,
|
||||
@@ -4216,7 +4284,9 @@ function App(): React.JSX.Element {
|
||||
detail: [
|
||||
event.routingMode === 'smart'
|
||||
? tRef.current('chat.subagents.smart')
|
||||
: tRef.current('chat.subagents.manual'),
|
||||
: event.routingMode === 'native'
|
||||
? tRef.current('chat.subagents.native')
|
||||
: tRef.current('chat.subagents.manual'),
|
||||
event.reason,
|
||||
event.error
|
||||
]
|
||||
@@ -4239,6 +4309,7 @@ function App(): React.JSX.Element {
|
||||
expertId: event.expertId,
|
||||
expertName: event.expertName,
|
||||
routingMode: event.routingMode,
|
||||
runtimeCallId: event.runtimeCallId,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
output: event.output,
|
||||
@@ -4246,10 +4317,27 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else if (subagents.length < 3) {
|
||||
} else if (
|
||||
subagents.length <
|
||||
maximumConversationSubagentActivities
|
||||
) {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
return { ...message, subagents }
|
||||
const runtimeCallId = event.runtimeCallId
|
||||
return {
|
||||
...message,
|
||||
subagents,
|
||||
tools: runtimeCallId
|
||||
? message.tools?.filter(
|
||||
(tool) => tool.callId !== runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks: upsertMessageSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
runtimeCallId
|
||||
)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'approval') {
|
||||
recordActivity({
|
||||
@@ -4342,6 +4430,12 @@ function App(): React.JSX.Element {
|
||||
? 'cancelled'
|
||||
: 'failed'
|
||||
: 'completed'
|
||||
const incompleteSubagentError = tRef.current(
|
||||
'chat.subagents.incomplete'
|
||||
)
|
||||
const incompleteActivityDetail = tRef.current(
|
||||
'chat.status.activityIncomplete'
|
||||
)
|
||||
updateRequestActivity(
|
||||
event.requestId,
|
||||
terminalStatus,
|
||||
@@ -4349,25 +4443,27 @@ function App(): React.JSX.Element {
|
||||
? event.message
|
||||
: tRef.current('chat.status.taskCompleted')
|
||||
)
|
||||
if (event.type === 'error') {
|
||||
setActivityRecords((current) =>
|
||||
current.map((record) =>
|
||||
record.requestId === event.requestId &&
|
||||
record.kind !== 'request' &&
|
||||
(record.status === 'pending' ||
|
||||
record.status === 'running')
|
||||
? {
|
||||
...record,
|
||||
status: terminalStatus,
|
||||
detail: `${record.detail}\n${event.message}`.slice(
|
||||
0,
|
||||
4_000
|
||||
)
|
||||
}
|
||||
: record
|
||||
)
|
||||
setActivityRecords((current) =>
|
||||
current.map((record) =>
|
||||
record.requestId === event.requestId &&
|
||||
record.kind !== 'request' &&
|
||||
(record.status === 'pending' ||
|
||||
record.status === 'running')
|
||||
? {
|
||||
...record,
|
||||
status:
|
||||
event.type === 'done'
|
||||
? 'interrupted'
|
||||
: terminalStatus,
|
||||
detail: `${record.detail}\n${
|
||||
event.type === 'done'
|
||||
? incompleteActivityDetail
|
||||
: event.message
|
||||
}`.slice(0, 4_000)
|
||||
}
|
||||
: record
|
||||
)
|
||||
}
|
||||
)
|
||||
recordActivity({
|
||||
conversationId: run.conversationId,
|
||||
requestId: event.requestId,
|
||||
@@ -4382,6 +4478,25 @@ function App(): React.JSX.Element {
|
||||
: tRef.current('chat.status.runtimeCompleted'),
|
||||
status: terminalStatus
|
||||
})
|
||||
setAssistantTasks((current) =>
|
||||
current.map((task) =>
|
||||
task.parentTaskId === event.requestId &&
|
||||
(task.status === 'queued' || task.status === 'running')
|
||||
? {
|
||||
...task,
|
||||
status:
|
||||
event.type === 'done'
|
||||
? 'failed'
|
||||
: terminalStatus,
|
||||
completedAt: new Date().toISOString(),
|
||||
error:
|
||||
event.type === 'error'
|
||||
? event.message
|
||||
: incompleteSubagentError
|
||||
}
|
||||
: task
|
||||
)
|
||||
)
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const representedToolError =
|
||||
event.type === 'error' &&
|
||||
@@ -4394,7 +4509,7 @@ function App(): React.JSX.Element {
|
||||
? event.status === 'cancelled'
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
: undefined
|
||||
: ('interrupted' as const)
|
||||
const fallbackError =
|
||||
event.type === 'error' &&
|
||||
!representedToolError &&
|
||||
@@ -4436,6 +4551,26 @@ function App(): React.JSX.Element {
|
||||
: tool
|
||||
)
|
||||
: message.tools,
|
||||
subagents: message.subagents?.map((subagent) =>
|
||||
subagent.state === 'queued' ||
|
||||
subagent.state === 'running'
|
||||
? {
|
||||
...subagent,
|
||||
state:
|
||||
event.type === 'error'
|
||||
? event.status === 'cancelled'
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
: ('failed' as const),
|
||||
...(event.type === 'error' &&
|
||||
event.status !== 'cancelled'
|
||||
? { error: event.message.slice(0, 1_000) }
|
||||
: event.type === 'done'
|
||||
? { error: incompleteSubagentError }
|
||||
: {})
|
||||
}
|
||||
: subagent
|
||||
),
|
||||
blocks: toolTerminalState
|
||||
? terminalizeMessageToolBlocks(
|
||||
appendMessageContentBlock(
|
||||
|
||||
@@ -246,26 +246,110 @@ describe('ChatTimeline', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const region = screen.getByLabelText('子专家状态')
|
||||
const region = screen.getByLabelText('子代理状态')
|
||||
const summary = screen.getByText('综合结果').parentElement
|
||||
const messageBody = region.parentElement
|
||||
expect(summary?.parentElement).toBe(messageBody)
|
||||
expect(
|
||||
Array.from(messageBody?.children ?? []).indexOf(region)
|
||||
).toBeLessThan(
|
||||
).toBeGreaterThan(
|
||||
Array.from(messageBody?.children ?? []).indexOf(summary!)
|
||||
)
|
||||
const cards = within(region).getAllByRole('group')
|
||||
expect(cards).toHaveLength(3)
|
||||
const experts = ['研究专家', '代码专家', '安全专家']
|
||||
for (const [index, output] of [
|
||||
'研究专家的独立结论',
|
||||
'代码专家的独立结论',
|
||||
'安全专家的独立结论'
|
||||
].entries()) {
|
||||
expect(within(cards[index]!).getByText(output)).toBeInTheDocument()
|
||||
expect(
|
||||
within(cards[index]!).queryByText(output)
|
||||
).not.toBeInTheDocument()
|
||||
expect(cards[index]).not.toHaveAttribute('open')
|
||||
fireEvent.click(within(cards[index]!).getByText(/专家$/u))
|
||||
fireEvent.click(
|
||||
within(cards[index]!).getByText(experts[index]!, {
|
||||
selector: 'strong'
|
||||
})
|
||||
)
|
||||
fireEvent(cards[index]!, new Event('toggle'))
|
||||
expect(cards[index]).toHaveAttribute('open')
|
||||
expect(within(cards[index]!).getByText(output)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('labels native OpenCode subagents and shows their task description', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'streaming',
|
||||
blocks: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000121',
|
||||
type: 'reasoning',
|
||||
content: '先分析桌面目录'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000122',
|
||||
type: 'subagent',
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000111'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000123',
|
||||
type: 'text',
|
||||
content: '父 Agent 最终回复'
|
||||
}
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
childTaskId: '00000000-0000-4000-8000-000000000111',
|
||||
expertId: '00000000-0000-4000-8000-000000000211',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'running',
|
||||
reason: 'Review application architecture'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
render(
|
||||
<ChatTimeline
|
||||
artifactById={new Map()}
|
||||
conversationId="conversation-1"
|
||||
hiddenMessageCount={0}
|
||||
isUnusedConversation={false}
|
||||
locale="zh-CN"
|
||||
messageStartIndex={0}
|
||||
messages={messages}
|
||||
{...callbacks}
|
||||
retryContent=""
|
||||
totalMessageCount={messages.length}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('子代理')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenCode 原生')).toBeInTheDocument()
|
||||
expect(screen.getByText('任务')).toBeInTheDocument()
|
||||
expect(screen.getByText('进行中')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Review application architecture')
|
||||
).toBeInTheDocument()
|
||||
const assistantArticle = screen
|
||||
.getByText('父 Agent 最终回复')
|
||||
.closest('article')
|
||||
const orderedBlocks = [
|
||||
...assistantArticle!.querySelectorAll('.message-blocks > *')
|
||||
].map((element) => element.textContent)
|
||||
expect(orderedBlocks).toEqual([
|
||||
expect.stringContaining('先分析桌面目录'),
|
||||
expect.stringContaining('Review application architecture'),
|
||||
expect.stringContaining('父 Agent 最终回复')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Download,
|
||||
FileText,
|
||||
Library,
|
||||
ClockFading,
|
||||
LoaderCircle,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
UserRound
|
||||
UserRound,
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
@@ -78,19 +83,40 @@ export type ImageViewerItem = {
|
||||
type MessageBlockRenderItem =
|
||||
| {
|
||||
kind: 'block'
|
||||
block: Exclude<ConversationMessageBlock, { type: 'tool' }>
|
||||
block: Extract<
|
||||
ConversationMessageBlock,
|
||||
{ type: 'text' | 'reasoning' }
|
||||
>
|
||||
}
|
||||
| {
|
||||
kind: 'tools'
|
||||
id: string
|
||||
tools: ToolActivity[]
|
||||
}
|
||||
| {
|
||||
kind: 'subagents'
|
||||
id: string
|
||||
childTaskIds: string[]
|
||||
}
|
||||
|
||||
function groupMessageBlocks(
|
||||
blocks: ConversationMessageBlock[]
|
||||
): MessageBlockRenderItem[] {
|
||||
const items: MessageBlockRenderItem[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'subagent') {
|
||||
const previous = items.at(-1)
|
||||
if (previous?.kind === 'subagents') {
|
||||
previous.childTaskIds.push(block.childTaskId)
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'subagents',
|
||||
id: block.id,
|
||||
childTaskIds: [block.childTaskId]
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (block.type !== 'tool') {
|
||||
items.push({ kind: 'block', block })
|
||||
continue
|
||||
@@ -219,6 +245,90 @@ function ToolExecutionList({
|
||||
)
|
||||
}
|
||||
|
||||
const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
subagent
|
||||
}: {
|
||||
subagent: SubagentActivity
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const StateIcon =
|
||||
subagent.state === 'completed'
|
||||
? CheckCircle2
|
||||
: subagent.state === 'running'
|
||||
? LoaderCircle
|
||||
: subagent.state === 'queued'
|
||||
? Clock3
|
||||
: XCircle
|
||||
const source =
|
||||
subagent.routingMode === 'smart'
|
||||
? t('chat.subagents.smart')
|
||||
: subagent.routingMode === 'native'
|
||||
? t('chat.subagents.native')
|
||||
: t('chat.subagents.manual')
|
||||
|
||||
return (
|
||||
<details
|
||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
<span className="subagent-status-card__identity">
|
||||
<span className="subagent-status-card__heading">
|
||||
<span className="subagent-status-card__badge">
|
||||
<Bot aria-hidden="true" size={13} />
|
||||
{t('chat.subagents.badge')}
|
||||
</span>
|
||||
<strong>{subagent.expertName}</strong>
|
||||
</span>
|
||||
{subagent.reason && (
|
||||
<span className="subagent-status-card__task">
|
||||
<small>{t('chat.subagents.task')}</small>
|
||||
<span>{subagent.reason}</span>
|
||||
</span>
|
||||
)}
|
||||
<small className="subagent-status-card__source">
|
||||
{source}
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
|
||||
>
|
||||
<StateIcon aria-hidden="true" size={14} />
|
||||
{t(`chat.subagents.states.${subagent.state}`)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="subagent-status-card__chevron"
|
||||
size={15}
|
||||
/>
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
{subagent.error &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
<section className="subagent-status-card__error">
|
||||
<strong>{t('chat.subagents.error')}</strong>
|
||||
<p>{subagent.error}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
})
|
||||
|
||||
function SubagentStatusList({
|
||||
subagents
|
||||
}: {
|
||||
@@ -231,44 +341,11 @@ function SubagentStatusList({
|
||||
aria-label={t('chat.subagents.region')}
|
||||
className="subagent-status-list"
|
||||
>
|
||||
{subagents.slice(0, 3).map((subagent) => (
|
||||
<details
|
||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||
{subagents.map((subagent) => (
|
||||
<SubagentStatusCard
|
||||
key={subagent.childTaskId}
|
||||
>
|
||||
<summary>
|
||||
<Bot aria-hidden="true" size={15} />
|
||||
<span className="subagent-status-card__identity">
|
||||
<strong>{subagent.expertName}</strong>
|
||||
<small>
|
||||
{subagent.routingMode === 'smart'
|
||||
? t('chat.subagents.smart')
|
||||
: t('chat.subagents.manual')}
|
||||
</small>
|
||||
</span>
|
||||
<span>{t(`chat.subagents.states.${subagent.state}`)}</span>
|
||||
</summary>
|
||||
<div className="subagent-status-card__details">
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
{(subagent.error || subagent.reason) &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
<section className="subagent-status-card__error">
|
||||
<strong>{t('chat.subagents.error')}</strong>
|
||||
<p>{subagent.error ?? subagent.reason}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
subagent={subagent}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
@@ -327,6 +404,20 @@ function ChatMessageRowView({
|
||||
const compressionMarkers =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression ? [message.contextCompression] : [])
|
||||
const subagentsById = new Map(
|
||||
(message.subagents ?? []).map((subagent) => [
|
||||
subagent.childTaskId,
|
||||
subagent
|
||||
])
|
||||
)
|
||||
const orderedSubagentIds = new Set(
|
||||
message.blocks
|
||||
?.filter((block) => block.type === 'subagent')
|
||||
.map((block) => block.childTaskId)
|
||||
)
|
||||
const unorderedSubagents = message.subagents?.filter(
|
||||
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
|
||||
)
|
||||
const compressionLabel = (
|
||||
compression: ConversationContextCompressionMarker
|
||||
): string =>
|
||||
@@ -468,14 +559,19 @@ function ChatMessageRowView({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{message.subagents && message.subagents.length > 0 && (
|
||||
<SubagentStatusList subagents={message.subagents} />
|
||||
)}
|
||||
{message.blocks && message.blocks.length > 0 ? (
|
||||
<div className="message-blocks">
|
||||
{groupMessageBlocks(message.blocks).map((item) =>
|
||||
item.kind === 'tools' ? (
|
||||
<ToolExecutionList key={item.id} tools={item.tools} />
|
||||
) : item.kind === 'subagents' ? (
|
||||
<SubagentStatusList
|
||||
key={item.id}
|
||||
subagents={item.childTaskIds.flatMap((childTaskId) => {
|
||||
const subagent = subagentsById.get(childTaskId)
|
||||
return subagent ? [subagent] : []
|
||||
})}
|
||||
/>
|
||||
) : item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
content={item.block.content}
|
||||
@@ -510,6 +606,9 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{unorderedSubagents && unorderedSubagents.length > 0 && (
|
||||
<SubagentStatusList subagents={unorderedSubagents} />
|
||||
)}
|
||||
{message.artifactIds?.map((artifactId) => {
|
||||
const candidate = artifactById.get(artifactId)
|
||||
const artifact =
|
||||
|
||||
@@ -322,6 +322,8 @@ export const app = {
|
||||
taskCompleted: 'Task completed',
|
||||
taskFailed: 'Task failed',
|
||||
runtimeCompleted: 'Agent Runtime completed its response',
|
||||
activityIncomplete:
|
||||
'The parent request ended before this activity reported completion',
|
||||
answerSubmitted: 'Answer submitted; OpenCode is continuing',
|
||||
questionSkipped: 'Question skipped'
|
||||
},
|
||||
@@ -345,12 +347,17 @@ export const app = {
|
||||
},
|
||||
subagents: {
|
||||
region: 'Subagent status',
|
||||
smart: 'Smart routing',
|
||||
manual: 'Selected manually',
|
||||
badge: 'Subagent',
|
||||
smart: 'GoodBuddy smart routing',
|
||||
manual: 'GoodBuddy selected expert',
|
||||
native: 'Native OpenCode',
|
||||
fallbackTask: '{{name}} subagent task',
|
||||
output: 'Expert output',
|
||||
task: 'Task',
|
||||
output: 'Subagent output',
|
||||
error: 'Execution details',
|
||||
noOutput: 'This expert has no output to display yet.',
|
||||
noOutput: 'This subagent has no output to display yet.',
|
||||
incomplete:
|
||||
'The parent request ended before the subagent reported completion.',
|
||||
states: {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
|
||||
@@ -308,6 +308,7 @@ export const app = {
|
||||
taskCompleted: '任务执行完成',
|
||||
taskFailed: '任务执行失败',
|
||||
runtimeCompleted: 'Agent Runtime 已完成响应',
|
||||
activityIncomplete: '父请求已结束,但该活动未报告完成状态',
|
||||
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
|
||||
questionSkipped: '已跳过问题'
|
||||
},
|
||||
@@ -330,13 +331,17 @@ export const app = {
|
||||
}
|
||||
},
|
||||
subagents: {
|
||||
region: '子专家状态',
|
||||
smart: '智能路由',
|
||||
manual: '手动指定',
|
||||
region: '子代理状态',
|
||||
badge: '子代理',
|
||||
smart: 'GoodBuddy 智能路由',
|
||||
manual: 'GoodBuddy 指定专家',
|
||||
native: 'OpenCode 原生',
|
||||
fallbackTask: '{{name}} 子专家任务',
|
||||
output: '专家输出',
|
||||
task: '任务',
|
||||
output: '子代理输出',
|
||||
error: '执行说明',
|
||||
noOutput: '该专家暂时没有可显示的输出。',
|
||||
noOutput: '子代理暂时没有可显示的输出。',
|
||||
incomplete: '父请求已结束,但子 Agent 未报告完成状态。',
|
||||
states: {
|
||||
queued: '等待中',
|
||||
running: '进行中',
|
||||
|
||||
+114
-15
@@ -4907,7 +4907,8 @@ button > svg {
|
||||
}
|
||||
|
||||
.message-blocks .message-reasoning,
|
||||
.message-blocks .tool-execution-list {
|
||||
.message-blocks .tool-execution-list,
|
||||
.message-blocks .subagent-status-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -5259,25 +5260,25 @@ button > svg {
|
||||
|
||||
.subagent-status-list {
|
||||
display: grid;
|
||||
margin-top: var(--space-2);
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subagent-status-card {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subagent-status-card > summary {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
align-items: start;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -5294,38 +5295,130 @@ button > svg {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.subagent-status-card--queued {
|
||||
box-shadow: inset 3px 0 0 var(--warning);
|
||||
}
|
||||
|
||||
.subagent-status-card--running {
|
||||
border-color: var(--accent-selected);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.subagent-status-card--completed {
|
||||
box-shadow: inset 3px 0 0 var(--success);
|
||||
}
|
||||
|
||||
.subagent-status-card--failed,
|
||||
.subagent-status-card--cancelled {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-subtle);
|
||||
box-shadow: inset 3px 0 0 var(--danger);
|
||||
}
|
||||
|
||||
.subagent-status-card__identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subagent-status-card__heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subagent-status-card__badge {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
padding: 2px var(--space-2);
|
||||
align-items: center;
|
||||
border: 1px solid var(--accent-selected);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 700;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.subagent-status-card summary strong,
|
||||
.subagent-status-card summary > span:last-child {
|
||||
.subagent-status-card__heading > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
font-size: var(--font-section-title);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subagent-status-card summary small {
|
||||
.subagent-status-card__task {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.subagent-status-card__task > small,
|
||||
.subagent-status-card__source {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.subagent-status-card__task > span {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subagent-status-card__status {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
padding: 2px var(--space-2);
|
||||
align-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 700;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagent-status-card__status--queued {
|
||||
background: var(--warning-subtle);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.subagent-status-card__status--running {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subagent-status-card__status--completed {
|
||||
background: var(--success-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.subagent-status-card__status--failed,
|
||||
.subagent-status-card__status--cancelled {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.subagent-status-card__status--running > svg {
|
||||
animation: subagent-status-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.subagent-status-card__chevron {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.subagent-status-card[open] .subagent-status-card__chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.subagent-status-card__details {
|
||||
display: grid;
|
||||
padding: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface-subtle);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -5349,6 +5442,12 @@ button > svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@keyframes subagent-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
@@ -242,4 +242,49 @@ describe('conversation persistence contracts', () => {
|
||||
|
||||
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
|
||||
})
|
||||
|
||||
it('accepts persisted native OpenCode subagent activity', () => {
|
||||
const batch = [
|
||||
{
|
||||
header: {
|
||||
id: '00000000-0000-4000-8000-000000000311',
|
||||
title: 'OpenCode 子 Agent',
|
||||
updatedAt: 1
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000312',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
blocks: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000315',
|
||||
type: 'subagent',
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000313'
|
||||
}
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000313',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000314',
|
||||
expertName: 'explorer',
|
||||
routingMode: 'native',
|
||||
runtimeCallId: 'call-task-1',
|
||||
state: 'completed',
|
||||
reason: 'Review application architecture',
|
||||
output: 'Architecture review complete.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,6 +172,13 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
type: z.literal('tool'),
|
||||
tool: conversationToolActivitySchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('subagent'),
|
||||
childTaskId: assistantIdSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
@@ -186,12 +193,16 @@ export type ConversationMessageBlock = z.infer<
|
||||
typeof conversationMessageBlockSchema
|
||||
>
|
||||
|
||||
export const maximumConversationToolActivities = 100
|
||||
export const maximumConversationSubagentActivities = 100
|
||||
|
||||
export const conversationSubagentActivitySchema = z
|
||||
.object({
|
||||
childTaskId: assistantIdSchema,
|
||||
expertId: assistantIdSchema,
|
||||
expertName: z.string().trim().min(1).max(80),
|
||||
routingMode: z.enum(['manual', 'smart']),
|
||||
routingMode: z.enum(['manual', 'smart', 'native']),
|
||||
runtimeCallId: z.string().trim().min(1).max(256).optional(),
|
||||
state: z.enum([
|
||||
'queued',
|
||||
'running',
|
||||
@@ -240,10 +251,13 @@ export const conversationMessageSchema = z
|
||||
.array(conversationContextCompressionMarkerSchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).max(100).optional(),
|
||||
tools: z
|
||||
.array(conversationToolActivitySchema)
|
||||
.max(maximumConversationToolActivities)
|
||||
.optional(),
|
||||
subagents: z
|
||||
.array(conversationSubagentActivitySchema)
|
||||
.max(3)
|
||||
.max(maximumConversationSubagentActivities)
|
||||
.optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
|
||||
+6
-18
@@ -21,6 +21,7 @@ import {
|
||||
conversationAttachmentSchema,
|
||||
conversationHistoryMessageSchema,
|
||||
conversationContextCompressionStateSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
legacyWorkModeSchema,
|
||||
maximumConversationHistoryCharacters,
|
||||
maximumConversationHistoryMessages,
|
||||
@@ -830,7 +831,7 @@ export const runtimeSettingsInputSchema = z
|
||||
code: 'custom',
|
||||
path: ['deepseekHarnessModelSource'],
|
||||
message:
|
||||
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||
})
|
||||
}
|
||||
const compressionSource = settings.contextCompression?.modelSource
|
||||
@@ -1171,24 +1172,11 @@ export const approvalDecisionSchema = z.enum([
|
||||
|
||||
export type ApprovalDecision = z.infer<typeof approvalDecisionSchema>
|
||||
|
||||
export const subagentEventSchema = z
|
||||
.object({
|
||||
export const subagentEventSchema =
|
||||
conversationSubagentActivitySchema
|
||||
.extend({
|
||||
requestId: z.string().uuid(),
|
||||
type: z.literal('subagent'),
|
||||
childTaskId: z.string().uuid(),
|
||||
expertId: z.string().uuid(),
|
||||
expertName: z.string().trim().min(1).max(80),
|
||||
routingMode: z.enum(['manual', 'smart']),
|
||||
state: z.enum([
|
||||
'queued',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]),
|
||||
reason: z.string().trim().min(1).max(240).optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().trim().min(1).max(1_000).optional()
|
||||
type: z.literal('subagent')
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
modelBridgePolicySchema
|
||||
} from './model-bridge-contracts'
|
||||
|
||||
export const UNBOUNDED_REMOTE_PROMPT_DEADLINE =
|
||||
'9999-12-31T23:59:59.999Z'
|
||||
|
||||
export const runtimeSessionBindingStateSchema = z.enum([
|
||||
'opening',
|
||||
'ready',
|
||||
|
||||
Reference in New Issue
Block a user