fix: preserve tool details and prevent repeated history growth
Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime. Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions. Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks. Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
This commit is contained in:
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(existsSync(globalDirectory)).toBe(false)
|
||||
})
|
||||
|
||||
it('uses auto mode and returns audit metadata for agent tools', async () => {
|
||||
it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
|
||||
const distribution = await createDistribution()
|
||||
let launchArgs: string[] = []
|
||||
const permissionBodies: unknown[] = []
|
||||
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
|
||||
toolCall: {
|
||||
function: { name: 'Bash' }
|
||||
},
|
||||
status: 'done'
|
||||
status: toolState
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
},
|
||||
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
|
||||
name: 'Bash',
|
||||
state: 'running',
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}'
|
||||
'{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
'Tests passed\nAuthorization: Bearer secret-token',
|
||||
input: '{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test'
|
||||
}
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
copyFile,
|
||||
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
input: z.string().max(4_000).optional(),
|
||||
summary: z.string().max(240).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(1_000).optional()
|
||||
})
|
||||
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: string
|
||||
summary?: string
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -729,6 +732,10 @@ function extractContinueTools(
|
||||
callId,
|
||||
name: name.trim().slice(0, 200),
|
||||
state: normalizedState,
|
||||
summary: toolOperationSummary(
|
||||
(toolFunction as Record<string, unknown>).arguments,
|
||||
state.title
|
||||
),
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
@@ -749,6 +756,7 @@ function mergeContinueTools(
|
||||
...previous,
|
||||
...tool,
|
||||
input: tool.input ?? previous?.input,
|
||||
summary: tool.summary ?? previous?.summary,
|
||||
output: tool.output ?? previous?.output,
|
||||
error: tool.error ?? previous?.error
|
||||
})
|
||||
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
|
||||
serverStateMarker,
|
||||
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
'state:"running",input:(()=>',
|
||||
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateEndpointMarker,
|
||||
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
summary: toolOperationSummary(event.input, event.summary),
|
||||
...(event.input
|
||||
? { input: boundedToolDetail(event.input, 4_000) }
|
||||
: {}),
|
||||
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
|
||||
: {})
|
||||
}
|
||||
observedTools = mergeContinueTools(observedTools, [tool])
|
||||
await runOptions.onEvent?.({ type: 'tool', tool })
|
||||
await runOptions.onEvent?.({
|
||||
type: 'tool',
|
||||
tool: observedTools.find((item) => item.callId === tool.callId)!
|
||||
})
|
||||
}
|
||||
const pendingQuestion = state.goodbuddyQuestion
|
||||
if (
|
||||
|
||||
@@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => {
|
||||
type: 'tool',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
summary: 'Continue 工具:Bash',
|
||||
summary: 'npm test',
|
||||
input: '{"command":"npm test"}',
|
||||
output: 'Tests passed'
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentEvent,
|
||||
@@ -88,7 +89,7 @@ function toContinueToolEvent(
|
||||
: terminalize && tool.state !== 'completed'
|
||||
? 'failed'
|
||||
: tool.state,
|
||||
summary: `Continue 工具:${tool.name}`,
|
||||
summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
|
||||
@@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
|
||||
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
|
||||
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
@@ -383,6 +385,49 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const canonical = await realpath(workspace)
|
||||
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream: () => textResponse('PATH_OK')
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const manager = new SelectedRuntimeManager(async (_selection, space) => {
|
||||
if (space?.kind !== 'local') throw new Error('local workspace required')
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
|
||||
model: 'path-test', launch,
|
||||
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
|
||||
})
|
||||
})
|
||||
try {
|
||||
expect(mixed).not.toBe(canonical)
|
||||
expect(await realpath(mixed)).toBe(canonical)
|
||||
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
|
||||
for (const [index, spelling] of [mixed, canonical].entries()) {
|
||||
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
|
||||
expect(runtime).toBe(first)
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `path-${index}`, conversationId: `path-${index}`,
|
||||
workMode: 'ask', prompt: 'Return PATH_OK.'
|
||||
}, AbortSignal.timeout(20_000)))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
|
||||
}
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
@@ -243,7 +244,7 @@ function setup(
|
||||
})
|
||||
)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: 'C:\\workspace',
|
||||
defaultWorkspace: process.cwd(),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
@@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
await expect(status).resolves.toMatchObject({
|
||||
@@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
await expect(status).resolves.toMatchObject({
|
||||
@@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
harness.child.stdout
|
||||
)
|
||||
expect(harness.launch).toHaveBeenCalledWith({
|
||||
cwd: 'C:\\workspace',
|
||||
cwd: await realpath(process.cwd()),
|
||||
signal: expect.any(AbortSignal),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
@@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps the original tool name on generic completion updates', async () => {
|
||||
it.each([
|
||||
['completed', undefined, 'GoodBuddy'],
|
||||
['failed', undefined, 'GoodBuddy'],
|
||||
['completed', 'Search the release notes', 'Search the release notes'],
|
||||
['failed', 'Search the release notes', 'Search the release notes']
|
||||
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
@@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
title,
|
||||
status: 'pending',
|
||||
rawInput: { query: 'GoodBuddy' }
|
||||
})
|
||||
@@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-web-search',
|
||||
name: 'tool',
|
||||
status: 'completed',
|
||||
status,
|
||||
rawOutput: 'search result'
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
@@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
state: 'pending'
|
||||
state: 'pending',
|
||||
summary
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
state: 'completed'
|
||||
state: status,
|
||||
summary,
|
||||
input: expect.stringContaining('GoodBuddy')
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentRuntimeStatus,
|
||||
ModelRequestHeaders,
|
||||
@@ -210,12 +212,16 @@ type ActiveRun = {
|
||||
toolController: AbortController
|
||||
authorize?: RuntimeAuthorizer
|
||||
updates: AcpSessionNotification['update'][]
|
||||
toolNames: Map<string, string>
|
||||
toolNames: Map<
|
||||
string,
|
||||
{ name: string; input?: string; summary?: string; title?: string }
|
||||
>
|
||||
wake?: () => void
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
type HarnessState = {
|
||||
workspace: string
|
||||
child: DeepSeekHarnessChild
|
||||
connection: AcpConnection
|
||||
agent: AcpAgent
|
||||
@@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.launchController = launchController
|
||||
let child: DeepSeekHarnessChild | undefined
|
||||
try {
|
||||
const workspace = await realpath(this.options.defaultWorkspace)
|
||||
child = await withTimeout(
|
||||
this.options.launch({
|
||||
cwd: this.options.defaultWorkspace,
|
||||
cwd: workspace,
|
||||
signal: launchController.signal,
|
||||
baseUrl: this.options.baseUrl,
|
||||
model: this.options.model,
|
||||
@@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
|
||||
}
|
||||
const stateWithoutCapabilities = {
|
||||
workspace,
|
||||
child,
|
||||
connection,
|
||||
agent
|
||||
@@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const creation = state.agent
|
||||
.newSession({
|
||||
cwd: this.options.defaultWorkspace,
|
||||
cwd: state.workspace,
|
||||
mcpServers: []
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private toRuntimeEvent(
|
||||
requestId: string,
|
||||
update: AcpSessionNotification['update'],
|
||||
toolNames: Map<string, string>
|
||||
toolNames: ActiveRun['toolNames']
|
||||
): RuntimeEvent | undefined {
|
||||
if (update.goodBuddyEvent) {
|
||||
return this.toUsageEvent(
|
||||
@@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
'DeepSeek Harness 工具'
|
||||
).slice(0, 200)
|
||||
const callId = update.toolCallId.slice(0, 256)
|
||||
const previous = toolNames.get(callId)
|
||||
const name =
|
||||
reportedName === 'tool'
|
||||
? toolNames.get(callId) ?? reportedName
|
||||
reportedName === 'tool' || (!update.name && !update.title)
|
||||
? previous?.name ?? reportedName
|
||||
: reportedName
|
||||
const title = toolOperationSummary(
|
||||
undefined,
|
||||
update.title === 'tool' || update.title === update.name || update.title === previous?.name
|
||||
? undefined : update.title
|
||||
) ?? previous?.title
|
||||
const input = safeStringify(update.rawInput) ?? previous?.input
|
||||
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
|
||||
if (state === 'pending' || state === 'running') {
|
||||
toolNames.set(callId, name)
|
||||
toolNames.set(callId, { name, input, summary, title })
|
||||
} else {
|
||||
toolNames.delete(callId)
|
||||
}
|
||||
const input = safeStringify(update.rawInput)
|
||||
const output = safeStringify(update.rawOutput)
|
||||
return {
|
||||
requestId,
|
||||
@@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
callId,
|
||||
name,
|
||||
state,
|
||||
summary: `DeepSeek Harness 工具:${name}`,
|
||||
summary: summary ?? `DeepSeek Harness 工具:${name}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {})
|
||||
}
|
||||
|
||||
@@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => {
|
||||
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
|
||||
})
|
||||
|
||||
it('runs approved direct-model tools and returns their results to OpenAI', async () => {
|
||||
it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
|
||||
const args = longInput
|
||||
? { content: 'x'.repeat(5_000), path: 'README.md' }
|
||||
: { path: 'README.md' }
|
||||
const responses = [
|
||||
{
|
||||
model: 'qwen3',
|
||||
@@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: '{"path":"README.md"}'
|
||||
arguments: JSON.stringify(args)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => {
|
||||
)
|
||||
expect(toolProvider.getApproval).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'workspace_read_text' }),
|
||||
{ path: 'README.md' },
|
||||
args,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
conversationId: 'conversation-tools',
|
||||
@@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => {
|
||||
)
|
||||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||||
'workspace_read_text',
|
||||
{ path: 'README.md' },
|
||||
args,
|
||||
expect.any(AbortSignal),
|
||||
expect.objectContaining({
|
||||
conversationId: 'conversation-tools',
|
||||
@@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => {
|
||||
.filter((event) => event.type === 'tool')
|
||||
.map((event) => event.state)
|
||||
).toEqual(['pending', 'running', 'completed'])
|
||||
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
|
||||
.toEqual(['README.md', 'README.md', 'README.md'])
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
state: 'completed',
|
||||
input: '{\n "path": "README.md"\n}',
|
||||
input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
|
||||
output:
|
||||
'tool result\n\n[图片结果 1:image/png]'
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { dirname } from 'node:path'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
@@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
tool?.name !== 'subagent_delegate'
|
||||
const displayName = tool?.displayName ?? call.name.slice(0, 128)
|
||||
const input = boundedToolDetail(call.arguments, 4_000)
|
||||
const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}`
|
||||
roundSummary.push(
|
||||
[
|
||||
`TOOL_CALL: ${displayName}`,
|
||||
@@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'pending',
|
||||
summary: `直连模型工具:${displayName}`,
|
||||
summary,
|
||||
input
|
||||
}
|
||||
}
|
||||
@@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'running',
|
||||
summary: `正在执行直连模型工具:${displayName}`,
|
||||
summary,
|
||||
input
|
||||
}
|
||||
}
|
||||
@@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'completed',
|
||||
summary: `直连模型工具已完成:${displayName}`,
|
||||
summary,
|
||||
input,
|
||||
output: getToolResultPreview(result.parts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
|
||||
describe('toolOperationSummary', () => {
|
||||
it.each(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])(
|
||||
'extracts %s without displaying file contents', (key) => {
|
||||
expect(toolOperationSummary({ content: 'x'.repeat(10_000), [key]: 'src/main.ts' }))
|
||||
.toBe('src/main.ts')
|
||||
}
|
||||
)
|
||||
it.each([
|
||||
[{ command: 'npm test\r\n-- --run', path: '/repo' }, 'npm test -- --run'],
|
||||
[{ pattern: 'TODO', path: 'src', filePattern: '*.ts' }, 'TODO | src | *.ts'],
|
||||
[{ query: 'latest release' }, 'latest release'],
|
||||
[{ glob: '**/*.ts' }, '**/*.ts'],
|
||||
[{ url: 'https://example.com' }, 'https://example.com']
|
||||
])('extracts command, search and URL parameters', (args, expected) => {
|
||||
expect(toolOperationSummary(JSON.stringify(args))).toBe(expected)
|
||||
})
|
||||
it.each([undefined, null, [], 12, 'broken JSON', '{"path":', '"text"', { content: 'body' }, { path: {}, command: 42 }])(
|
||||
'falls back for invalid or irrelevant input %j', (input) => {
|
||||
expect(toolOperationSummary(input)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
it('prefers a nonempty native title and bounds a single line to 240 characters', () => {
|
||||
expect(toolOperationSummary({ path: 'file' }, 'Read\n native\tfile')).toBe('Read native file')
|
||||
expect(toolOperationSummary({ path: 'file' }, ' \n ')).toBe('file')
|
||||
expect(toolOperationSummary({ command: 'x'.repeat(500) })).toHaveLength(240)
|
||||
expect(toolOperationSummary({}, 'x'.repeat(500))).toHaveLength(240)
|
||||
})
|
||||
it('can run in the Continue host bundle before input preview truncation', () => {
|
||||
const summarize = new Function(`return (${toolOperationSummary.toString()})`)() as typeof toolOperationSummary
|
||||
expect(summarize({ content: 'x'.repeat(10_000), filepath: 'report.md' })).toBe('report.md')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// Kept self-contained so the Continue host can use it before truncating input.
|
||||
export function toolOperationSummary(
|
||||
input: unknown,
|
||||
title?: unknown
|
||||
): string | undefined {
|
||||
const singleLine = (value: unknown): string =>
|
||||
typeof value === 'string'
|
||||
? [...value].map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 31 || code === 127 ? ' ' : character
|
||||
}).join('').replace(/\s+/gu, ' ').trim()
|
||||
: ''
|
||||
const nativeTitle = singleLine(title)
|
||||
if (nativeTitle) return nativeTitle.slice(0, 240)
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
input = JSON.parse(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
|
||||
const args = input as Record<string, unknown>
|
||||
const first = (keys: string[]): string =>
|
||||
keys.map((key) => singleLine(args[key])).find(Boolean) ?? ''
|
||||
const command = first(['command'])
|
||||
const path = first(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])
|
||||
const search = first(['query', 'pattern', 'glob'])
|
||||
const filter = first(['filePattern', 'include'])
|
||||
const summary = command || (search
|
||||
? [search, path, filter].filter(Boolean).join(' | ')
|
||||
: path || first(['url']))
|
||||
return summary ? summary.slice(0, 240) : undefined
|
||||
}
|
||||
@@ -95,9 +95,12 @@ import {
|
||||
setMagicNoteChecklistCompletion
|
||||
} from '../magic-notes/rich-content'
|
||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||
import { SubagentProgressStorage } from './subagent-progress-storage'
|
||||
import {
|
||||
SubagentProgressStorage,
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -5344,34 +5347,9 @@ export class AssistantDatabase {
|
||||
database: DatabaseSync,
|
||||
event: ValidatedRemoteTaskEvent
|
||||
): boolean {
|
||||
const payloadJson = this.subagentProgress!.serialize(
|
||||
event.taskId, event.kind, JSON.parse(event.payloadJson)
|
||||
)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO task_events
|
||||
(task_id, run_id, kind, payload_json, created_at,
|
||||
remote_binding_id, remote_operation_id,
|
||||
remote_semantic_sequence, remote_event_index)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
event.taskId,
|
||||
event.kind,
|
||||
payloadJson,
|
||||
new Date().toISOString(),
|
||||
event.bindingId,
|
||||
event.operationId,
|
||||
event.semanticSequence,
|
||||
event.eventIndex
|
||||
)
|
||||
if (result.changes === 1) {
|
||||
this.subagentProgress!.inserted(
|
||||
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
return true
|
||||
}
|
||||
const existing = database
|
||||
const findExisting = (): {
|
||||
id: number; task_id: string; kind: string; payload_json: string
|
||||
} | undefined => database
|
||||
.prepare(
|
||||
`SELECT id, task_id, kind, payload_json
|
||||
FROM task_events
|
||||
@@ -5393,6 +5371,38 @@ export class AssistantDatabase {
|
||||
payload_json: string
|
||||
}
|
||||
| undefined
|
||||
// Duplicate replay must not recreate a completed task's released write cache.
|
||||
let existing = findExisting()
|
||||
if (!existing) {
|
||||
const payloadJson = this.subagentProgress!.serialize(
|
||||
event.taskId, event.kind, JSON.parse(event.payloadJson)
|
||||
)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO task_events
|
||||
(task_id, run_id, kind, payload_json, created_at,
|
||||
remote_binding_id, remote_operation_id,
|
||||
remote_semantic_sequence, remote_event_index)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
event.taskId,
|
||||
event.kind,
|
||||
payloadJson,
|
||||
new Date().toISOString(),
|
||||
event.bindingId,
|
||||
event.operationId,
|
||||
event.semanticSequence,
|
||||
event.eventIndex
|
||||
)
|
||||
if (result.changes === 1) {
|
||||
this.subagentProgress!.inserted(
|
||||
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
return true
|
||||
}
|
||||
existing = findExisting()
|
||||
}
|
||||
if (
|
||||
existing?.task_id === event.taskId &&
|
||||
existing.kind === event.kind &&
|
||||
@@ -9681,6 +9691,10 @@ export class AssistantDatabase {
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
if (version.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION) {
|
||||
// Startup's worker has already compacted full and schema-34 delta events.
|
||||
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -7,11 +7,12 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
restoreSubagentPayload,
|
||||
SubagentProgressStorage
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
const directories: string[] = []
|
||||
@@ -78,7 +79,10 @@ describe('subagent progress storage', () => {
|
||||
const event = subagent(fixtureData.taskId)
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
|
||||
tool: {
|
||||
name: 'read', summary: 'Read', state: 'completed',
|
||||
input: undefined, output: 'x'.repeat(200_000), error: undefined
|
||||
}
|
||||
}
|
||||
const textId = randomUUID()
|
||||
try {
|
||||
@@ -102,23 +106,116 @@ describe('subagent progress storage', () => {
|
||||
const state = new Map<string, ConversationMessageBlock[]>()
|
||||
let restored: unknown
|
||||
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
|
||||
expect(restored).toEqual(event)
|
||||
expect(restored).toStrictEqual(JSON.parse(JSON.stringify(event)))
|
||||
} finally { inspection.close() }
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('ignores omitted optional tool fields but preserves every actual field change', () => {
|
||||
const event = subagent(randomUUID())
|
||||
const block: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: {
|
||||
callId: 'read-1', name: 'read', summary: 'Read', state: 'completed',
|
||||
input: undefined, output: 'unchanged', error: undefined
|
||||
}
|
||||
}
|
||||
const state = new Map<string, ConversationMessageBlock[]>([
|
||||
[event.childTaskId, JSON.parse(JSON.stringify([block]))]
|
||||
])
|
||||
expect(compactSubagentPayload({ ...event, progress: [block] }, state))
|
||||
.toMatchObject({ progressUpdates: [] })
|
||||
for (const change of [
|
||||
{ callId: 'read-2' }, { name: 'write' }, { summary: 'Updated' },
|
||||
{ state: 'failed' as const }, { input: '' }, { output: 'changed' },
|
||||
{ error: 'failed' }, { output: undefined }
|
||||
]) {
|
||||
const updated = { ...block, tool: { ...block.tool, ...change } }
|
||||
const compact = compactSubagentPayload({ ...event, progress: [updated] }, state)
|
||||
expect(compact).toMatchObject({
|
||||
progressUpdates: [{ type: 'upsert', block: updated }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('releases terminal task caches without clearing peers, and rebuilds after rollback', () => {
|
||||
const database = new DatabaseSync(':memory:')
|
||||
database.exec(`CREATE TABLE task_events(
|
||||
id INTEGER PRIMARY KEY, task_id TEXT, kind TEXT, payload_json TEXT
|
||||
)`)
|
||||
const storage = new SubagentProgressStorage(database)
|
||||
const caches = (storage as unknown as { tasks: Map<string, unknown> }).tasks
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: {
|
||||
name: 'read', state: 'completed', summary: 'Read',
|
||||
output: 'x'.repeat(200_000), error: undefined
|
||||
}
|
||||
}
|
||||
let id = 0
|
||||
const append = (taskId: string, kind: string, event: unknown) => {
|
||||
const payload = storage.serialize(taskId, kind, event)
|
||||
database.prepare('INSERT INTO task_events VALUES (?, ?, ?, ?)')
|
||||
.run(++id, taskId, kind, payload)
|
||||
storage.inserted(taskId, kind, id, payload)
|
||||
return JSON.parse(payload) as { progressUpdates: unknown[] }
|
||||
}
|
||||
try {
|
||||
const peer = subagent(randomUUID())
|
||||
append(peer.requestId, 'subagent', { ...peer, progress: [tool] })
|
||||
for (const [kind, payload] of [
|
||||
['done', {}], ['error', {}],
|
||||
...['completed', 'failed', 'cancelled', 'interrupted']
|
||||
.map((status) => ['status', { status }])
|
||||
] as Array<[string, unknown]>) {
|
||||
const event = { ...subagent(randomUUID()), progress: [tool] }
|
||||
append(event.requestId, 'subagent', event)
|
||||
expect(caches.size).toBe(2)
|
||||
database.exec('BEGIN')
|
||||
append(event.requestId, kind, payload)
|
||||
expect(caches.has(event.requestId)).toBe(false)
|
||||
expect(caches.has(peer.requestId)).toBe(true)
|
||||
database.exec('ROLLBACK')
|
||||
expect(append(event.requestId, 'subagent', event).progressUpdates).toEqual([])
|
||||
append(event.requestId, kind, payload)
|
||||
expect(caches.size).toBe(1)
|
||||
}
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const event = subagent(randomUUID())
|
||||
append(event.requestId, 'subagent', { ...event, progress: [tool] })
|
||||
expect(caches.size).toBeLessThanOrEqual(16)
|
||||
}
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
const event = subagent(taskId)
|
||||
const textId = randomUUID()
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: {
|
||||
name: 'read', summary: 'Read', state: 'completed',
|
||||
input: undefined, output: 'x'.repeat(20_000), error: undefined
|
||||
}
|
||||
}
|
||||
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
|
||||
const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] }
|
||||
try {
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
|
||||
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
|
||||
database.appendTaskEvent(taskId, 'done', {})
|
||||
const caches = (database as unknown as {
|
||||
subagentProgress: { tasks: Map<string, unknown> }
|
||||
}).subagentProgress.tasks
|
||||
expect(caches.size).toBe(0)
|
||||
expect(database.appendRemoteTaskEventOnce({
|
||||
...provenance, semanticSequence: '2', payload: second
|
||||
})).toBe(false)
|
||||
expect(caches.size).toBe(0)
|
||||
} finally { database.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(tmpdir())
|
||||
@@ -208,7 +305,7 @@ describe('subagent progress storage', () => {
|
||||
} finally { inspection.close(); database.close() }
|
||||
})
|
||||
|
||||
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
|
||||
it.each([33, 34])('converts schema %i rows, resumes and shrinks without losing provenance', async (sourceVersion) => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
database.close()
|
||||
const event = subagent(taskId)
|
||||
@@ -220,10 +317,12 @@ describe('subagent progress storage', () => {
|
||||
const legacy = new DatabaseSync(path)
|
||||
const expected: SubagentEvent[] = []
|
||||
try {
|
||||
legacy.exec('PRAGMA user_version = 33; BEGIN')
|
||||
legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
|
||||
const insert = legacy.prepare(
|
||||
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
|
||||
VALUES (?, 'subagent', ?, ?)`
|
||||
`INSERT INTO task_events(
|
||||
task_id, kind, payload_json, created_at, remote_binding_id,
|
||||
remote_operation_id, remote_semantic_sequence, remote_event_index
|
||||
) VALUES (?, 'subagent', ?, ?, 'binding', 'operation', ?, 0)`
|
||||
)
|
||||
for (let index = 0; index < 150; index++) {
|
||||
const full = {
|
||||
@@ -231,9 +330,17 @@ describe('subagent progress storage', () => {
|
||||
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
|
||||
}
|
||||
expected.push(full)
|
||||
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
|
||||
const { progress, ...metadata } = full
|
||||
const stored = sourceVersion === 34 ? {
|
||||
...metadata,
|
||||
progressUpdates: progress.map((block) => ({ type: 'upsert', block }))
|
||||
} : full
|
||||
insert.run(taskId, JSON.stringify(stored), '2026-09-13T00:00:00Z', String(index + 1))
|
||||
}
|
||||
legacy.exec('COMMIT')
|
||||
legacy.exec(`COMMIT;
|
||||
CREATE TRIGGER reject_unchanged_payload BEFORE UPDATE OF payload_json ON task_events
|
||||
WHEN OLD.payload_json = NEW.payload_json
|
||||
BEGIN SELECT RAISE(ABORT, 'unchanged payload rewritten'); END;`)
|
||||
} finally { legacy.close() }
|
||||
const before = (await stat(path)).size
|
||||
expect(before).toBeGreaterThan(40_000_000)
|
||||
@@ -248,13 +355,25 @@ describe('subagent progress storage', () => {
|
||||
try {
|
||||
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
const rows = inspected.prepare(
|
||||
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{ payload_json: string; created_at: string }>
|
||||
`SELECT id, task_id, payload_json, created_at, remote_binding_id,
|
||||
remote_operation_id, remote_semantic_sequence, remote_event_index
|
||||
FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{
|
||||
id: number; task_id: string; payload_json: string; created_at: string
|
||||
remote_binding_id: string; remote_operation_id: string
|
||||
remote_semantic_sequence: string; remote_event_index: number
|
||||
}>
|
||||
expect(rows).toHaveLength(expected.length)
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
rows.forEach((row, index) => {
|
||||
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
|
||||
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
|
||||
expect(row.task_id).toBe(taskId)
|
||||
expect(row.remote_binding_id).toBe('binding')
|
||||
expect(row.remote_operation_id).toBe('operation')
|
||||
expect(row.remote_semantic_sequence).toBe(String(index + 1))
|
||||
expect(row.remote_event_index).toBe(0)
|
||||
if (index > 0) expect(row.id).toBe(rows[index - 1]!.id + 1)
|
||||
})
|
||||
} finally { inspected.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
@@ -263,5 +382,47 @@ describe('subagent progress storage', () => {
|
||||
const messages: unknown[] = []
|
||||
upgradeAssistantStorage(path, (value) => messages.push(value))
|
||||
expect(messages).toEqual([])
|
||||
const latest = new DatabaseSync(path)
|
||||
try {
|
||||
expect(latest.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
} finally { latest.close() }
|
||||
}, 30_000)
|
||||
|
||||
it('recompacts mixed full and delta events without changing corrections, resets or child state', () => {
|
||||
const first = subagent(randomUUID())
|
||||
const second = { ...subagent(first.requestId), state: 'completed' as const, output: 'Done' }
|
||||
const textId = randomUUID()
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', state: 'completed', summary: 'Read', output: 'payload' }
|
||||
}
|
||||
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
|
||||
const stored = [
|
||||
{ ...first, progress: [tool, text] },
|
||||
{ ...second, progressUpdates: [{ type: 'upsert', block: text }] },
|
||||
{ ...first, progress: undefined, progressUpdates: [
|
||||
{ type: 'upsert', block: tool },
|
||||
{ type: 'append', id: textId, blockType: 'text', delta: ' tail' }
|
||||
] },
|
||||
{ ...first, progress: undefined, progressUpdates: [
|
||||
{ type: 'upsert', block: { ...text, content: 'corrected' } }
|
||||
] },
|
||||
{ ...first, progress: undefined, progressUpdates: [
|
||||
{ type: 'reset', blocks: [text, tool] }
|
||||
] },
|
||||
{ ...second, progressUpdates: [] },
|
||||
{ ...first, progress: [] },
|
||||
{ ...first, progress: [text] }
|
||||
]
|
||||
const oldState = new Map<string, ConversationMessageBlock[]>()
|
||||
const newState = new Map<string, ConversationMessageBlock[]>()
|
||||
for (const input of stored) {
|
||||
const event = JSON.parse(JSON.stringify(input))
|
||||
const compact = compactSubagentPayload(event, oldState)
|
||||
const expected = restoreSubagentPayload(event, oldState)
|
||||
expect(restoreSubagentPayload(compact, newState)).toStrictEqual(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
restoreSubagentPayload,
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export function upgradeAssistantStorage(
|
||||
@@ -21,7 +22,10 @@ export function upgradeAssistantStorage(
|
||||
const version = database.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
if (version.user_version < 1 || version.user_version >= 34) return
|
||||
if (
|
||||
version.user_version < 1 ||
|
||||
version.user_version >= SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
) return
|
||||
checkCancelled()
|
||||
const progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore
|
||||
@@ -65,8 +69,9 @@ export function upgradeAssistantStorage(
|
||||
const compact = compactSubagentPayload(payload, blocks)
|
||||
// Replay mixed old/new rows on retry. Never delete events or provenance.
|
||||
restoreSubagentPayload(payload, blocks)
|
||||
if (compact !== payload) {
|
||||
update.run(JSON.stringify(compact), row.id)
|
||||
const compactJson = JSON.stringify(compact)
|
||||
if (compactJson !== row.payload_json) {
|
||||
update.run(compactJson, row.id)
|
||||
}
|
||||
afterId = row.id
|
||||
}
|
||||
|
||||
@@ -4,9 +4,28 @@ import type { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationMessageBlockSchema,
|
||||
type ConversationMessageBlock
|
||||
conversationToolActivitySchema,
|
||||
type ConversationMessageBlock,
|
||||
type ConversationToolActivity
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35
|
||||
|
||||
const toolFields = Object.keys(conversationToolActivitySchema.shape) as
|
||||
Array<keyof ConversationToolActivity>
|
||||
|
||||
function sameProgressBlock(
|
||||
previous: ConversationMessageBlock,
|
||||
next: ConversationMessageBlock
|
||||
): boolean {
|
||||
if (previous.type === 'tool' && next.type === 'tool') {
|
||||
// JSON omits undefined optional fields. Compare their values, not key presence.
|
||||
return previous.id === next.id &&
|
||||
toolFields.every((field) => previous.tool[field] === next.tool[field])
|
||||
}
|
||||
return isDeepStrictEqual(previous, next)
|
||||
}
|
||||
|
||||
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('append'),
|
||||
@@ -89,7 +108,7 @@ export function diffSubagentProgress(
|
||||
type: 'append', id: block.id, blockType: block.type, delta
|
||||
})
|
||||
}
|
||||
} else if (!prior || !isDeepStrictEqual(prior, block)) {
|
||||
} else if (!prior || !sameProgressBlock(prior, block)) {
|
||||
updates.push({ type: 'upsert', block })
|
||||
}
|
||||
}
|
||||
@@ -114,12 +133,15 @@ export function compactSubagentPayload(
|
||||
progress: ReadonlyMap<string, ConversationMessageBlock[]>
|
||||
): unknown {
|
||||
const event = asSubagent(payload)
|
||||
if (!event || !Array.isArray(event.progress)) return payload
|
||||
if (!event || (
|
||||
!Array.isArray(event.progress) && !Array.isArray(event.progressUpdates)
|
||||
)) return payload
|
||||
const previous = progress.get(event.childTaskId) ?? []
|
||||
const compact = { ...event }
|
||||
delete compact.progress
|
||||
compact.progressUpdates = diffSubagentProgress(
|
||||
progress.get(event.childTaskId) ?? [],
|
||||
event.progress
|
||||
previous,
|
||||
applySubagentProgress(previous, event) ?? []
|
||||
)
|
||||
return compact
|
||||
}
|
||||
@@ -158,7 +180,17 @@ export class SubagentProgressStorage {
|
||||
}
|
||||
|
||||
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
|
||||
if (kind !== 'subagent') return
|
||||
if (kind !== 'subagent') {
|
||||
if (
|
||||
kind === 'done' || kind === 'error' ||
|
||||
(kind === 'status' && [
|
||||
'completed', 'failed', 'cancelled', 'interrupted'
|
||||
].includes((JSON.parse(payloadJson) as { status?: string }).status ?? ''))
|
||||
) {
|
||||
this.tasks.delete(taskId)
|
||||
}
|
||||
return
|
||||
}
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task) return
|
||||
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
|
||||
|
||||
Reference in New Issue
Block a user