fix: resolve runtime, review, channel and chat state failures

Keep remote startup cancellable without blocking other sessions and surface local OpenCode submission failures promptly. Preserve supervisor project isolation, pause behavior and valid story destinations.

Restore generated-image channel delivery, Telegram reconnection on unchanged Save and DingTalk business-error retries. Preserve virtualized message disclosures using sparse state and release stale supervisor UI requests.

Add production-path regressions, Electron checks, isolated SQLite validation and audit evidence. Fix the SDK cleanup test fixture lifetime under garbage collection without weakening its assertions.

Validation: 6136 tests passed, 87 skipped; typecheck, lint and diff checks passed. Seven real text-model calls, current-source Linux x64 Host scenarios and large-history Electron comparisons completed.

Release note: Fix stuck runtime requests, inconsistent review results and channel delivery failures, and retain expanded details while browsing long conversations. Remote runtime fixes require an Agent build containing these changes.
This commit is contained in:
mesalogo
2026-10-05 10:25:43 +08:00
parent 126dbc57e2
commit bbf24c282a
49 changed files with 1710 additions and 106 deletions
@@ -23,6 +23,33 @@ afterEach(() => {
})
describe('AgentOwnedAcpPrompt', () => {
it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-'))
temporary.push(root)
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
transcript.prepare({ bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1',
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
let initialize!: (value: { protocolVersion: number }) => void
let finish!: (value: PromptResponse) => void
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
const prompt = vi.fn(() => new Promise<PromptResponse>(resolve => { finish = resolve }))
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding-1', controllerId: 'controller-1',
workspaceDirectory: '/workspace', process: new MemoryProcess(), transcript, completePrompt: async () => undefined,
createConnection: () => ({ initialize: () => new Promise(resolve => { initialize = resolve }), newSession, prompt }) as unknown as ClientSideConnection })
const request = { bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text' as const, text: 'test' }] }
try {
const first = owner.start(request)
const replay = owner.start(request)
await expect(owner.start({ ...request, prompt: [{ type: 'text', text: 'different' }] })).rejects.toMatchObject({ code: 'conflict' })
initialize({ protocolVersion: 1 })
expect(await first).toEqual(await replay)
expect(newSession).toHaveBeenCalledOnce()
expect(prompt).toHaveBeenCalledOnce()
finish({ stopReason: 'end_turn' })
await vi.waitFor(() => expect(transcript.attach('binding-1', 'operation-1', 'controller-1').state).toBe('completed'))
} finally { owner.close(); transcript.close() }
})
it('refreshes prompt tools on the retained session and authorizes execution', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
temporary.push(root)
+56 -12
View File
@@ -18,6 +18,7 @@ import type {
} from '../shared/remote-agent-contracts'
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
import { AgentAcpConnection } from './agent-acp-connection'
import { raceWithAbort } from './async-utils'
import {
SemanticPromptStore,
SemanticPromptStoreError
@@ -63,6 +64,11 @@ export class AgentOwnedAcpPrompt {
#promptSettled?: Promise<void>
#closed = false
#initialized = false
#starting?: {
digest: string
controller: AbortController
promise: Promise<RemoteOwnedPromptStartResult>
}
#hasMcpServers = false
readonly #questions = new Map<string, {
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
@@ -86,6 +92,7 @@ export class AgentOwnedAcpPrompt {
async start(
request: RemoteOwnedPromptStartRequest
): Promise<RemoteOwnedPromptStartResult> {
if (this.#closed) throw new Error('ACP Session is closed')
if (
request.bindingId !== this.#options.bindingId
) {
@@ -95,6 +102,12 @@ export class AgentOwnedAcpPrompt {
)
}
const startDigest = digest(canonicalJson(request))
if (this.#starting) {
if (this.#starting.digest !== startDigest) {
throw new SemanticPromptStoreError('Another ACP prompt is starting', 'conflict')
}
return this.#starting.promise
}
const existing = this.#options.transcript.findStarted({
bindingId: request.bindingId,
operationId: request.operationId,
@@ -111,9 +124,39 @@ export class AgentOwnedAcpPrompt {
)
}
const controller = new AbortController()
const promise = this.#start(request, startDigest, controller.signal)
this.#starting = { digest: startDigest, controller, promise }
try {
return await promise
} finally {
this.#starting = undefined
}
}
async #start(
request: RemoteOwnedPromptStartRequest,
startDigest: string,
signal: AbortSignal
): Promise<RemoteOwnedPromptStartResult> {
const whileStarting = async <T>(operation: Promise<T>): Promise<T> => {
const result = await raceWithAbort(this.#whileProcessAlive(operation), signal)
signal.throwIfAborted()
return result
}
const retainSession = async (operation: Promise<string>): Promise<string> =>
whileStarting(operation.then(sessionId => {
if (signal.aborted) {
// A native session can be created after its owner was cancelled.
void this.#connection.closeSession({ sessionId }).catch(() => undefined)
} else {
this.#sessionId = sessionId
}
return sessionId
}))
const mcpServers = this.#options.mcpServers?.() ?? []
if (!this.#initialized) {
const initialization = await this.#whileProcessAlive(
const initialization = await whileStarting(
this.#transport.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
@@ -134,38 +177,36 @@ export class AgentOwnedAcpPrompt {
}
if (request.acpSessionId !== undefined && this.#sessionId === undefined) {
if (initialization.agentCapabilities?.loadSession === true) {
await this.#whileProcessAlive(
this.#sessionId = await retainSession(
this.#connection.loadSession({
sessionId: request.acpSessionId,
cwd: this.#options.workspaceDirectory,
mcpServers
})
}).then(() => request.acpSessionId!)
)
} else if (
initialization.agentCapabilities?.sessionCapabilities?.resume != null
) {
await this.#whileProcessAlive(
this.#sessionId = await retainSession(
this.#connection.resumeSession({
sessionId: request.acpSessionId,
cwd: this.#options.workspaceDirectory,
mcpServers
})
}).then(() => request.acpSessionId!)
)
} else {
throw new Error('ACP Runtime cannot resume the requested session')
}
this.#sessionId = request.acpSessionId
} else if (this.#sessionId === undefined) {
const created = await this.#whileProcessAlive(
this.#sessionId = await retainSession(
this.#connection.newSession({
cwd: this.#options.workspaceDirectory,
mcpServers
})
}).then(created => created.sessionId)
)
this.#sessionId = created.sessionId
}
if (this.#options.expectedModel !== undefined) {
const configured = await this.#whileProcessAlive(
const configured = await whileStarting(
this.#connection.setSessionConfigOption({
sessionId: this.#sessionId!,
configId: 'model',
@@ -190,13 +231,14 @@ export class AgentOwnedAcpPrompt {
) {
throw new Error('ACP session identity cannot change within a binding')
} else if (mcpServers.length > 0 || this.#hasMcpServers) {
await this.#whileProcessAlive(this.#connection.resumeSession({
await whileStarting(this.#connection.resumeSession({
sessionId: this.#sessionId!, cwd: this.#options.workspaceDirectory,
mcpServers
}))
}
this.#hasMcpServers = mcpServers.length > 0
await this.#options.prepareSession?.(this.#sessionId!, request.operationId)
await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId)))
signal.throwIfAborted()
this.#active = {
operationId: request.operationId
}
@@ -251,6 +293,7 @@ export class AgentOwnedAcpPrompt {
}
async cancel(): Promise<void> {
this.#starting?.controller.abort(new Error('ACP prompt startup cancelled'))
this.#acceptQuestions = false
this.#questions.clear()
if (this.#sessionId !== undefined && !this.#closed) {
@@ -396,6 +439,7 @@ export class AgentOwnedAcpPrompt {
return
}
this.#closed = true
this.#starting?.controller.abort(new Error('ACP Session is closed'))
this.#acceptQuestions = false
this.#questions.clear()
this.#unregisterSession?.()
+24
View File
@@ -44,6 +44,30 @@ afterEach(() => {
})
describe('AgentProtocolServer connection bounds', () => {
it('dispatches cancellation and peer requests before a native startup response', async () => {
const gate = deferred<void>()
const calls: string[] = []
const harness = createHarness({ methods: {
'runtime/startPrompt': async () => { calls.push('start'); await gate.promise; return { started: true } },
'runtime/escalateCancellation': async () => { calls.push('cancel'); return { stopped: true } },
'runtime/getAcpCursors': async () => { calls.push('peer'); return {} }
} })
try {
harness.socket.receive(Buffer.concat([
controlRequest(harness.controller, 'start', '1', 1, 'runtime/startPrompt'),
controlRequest(harness.controller, 'cancel', '1', 1, 'runtime/escalateCancellation'),
controlRequest(harness.controller, 'peer', '1', 1, 'runtime/getAcpCursors')
].map(Buffer.from)))
await new Promise(resolve => setImmediate(resolve))
expect(calls).toEqual(['start', 'cancel', 'peer'])
expect(harness.socket.writes).toHaveLength(4)
gate.resolve()
await waitFor(() => harness.socket.writes.length === 6)
expect(harness.socket.maximumConcurrentWrites).toBe(1)
expect(harness.socket.destroyCalled).toBe(false)
} finally { gate.resolve(); harness.close() }
})
it('accepts a frame fragmented into single-byte socket chunks', async () => {
const harness = createHarness()
const frame = controlRequest(
+15 -5
View File
@@ -866,11 +866,19 @@ class ProtocolConnection {
if (!('id' in request)) {
await this.#invokeNotification(request, channel)
} else {
await this.#invokeRequest(request, channel)
await this.#sendChannelClose(channel)
this.#channels.close(channel.channelId)
this.#channelKinds.delete(channel.channelId)
return
const completion = this.#invokeRequest(request, channel).then(async () => {
if (this.#closed) return
await this.#sendChannelClose(channel)
this.#channels.close(channel.channelId)
this.#channelKinds.delete(channel.channelId)
})
if (request.method === 'runtime/startPrompt') {
// Startup holds its admitted request channel (and its existing capacity
// slot), but must leave the socket free for cancellation and peer work.
void completion.catch(error => this.#close(protocolFailureCategory('dispatch', error)))
} else {
await completion
}
}
}
@@ -923,6 +931,7 @@ class ProtocolConnection {
controllerTakeoverProven: this.#takeoverProven,
signal: this.#abortController.signal
})
if (this.#closed) return
if (request.method === 'runtime/resumeAcpChannel') {
this.#bindResumedAcpChannel(result)
}
@@ -941,6 +950,7 @@ class ProtocolConnection {
result: result ?? null
})
} catch (error) {
if (this.#closed) return
const serviceCode = typedErrorCode(error)
await this.#sendJson(channel, {
jsonrpc: '2.0',
@@ -518,6 +518,84 @@ function harness(input: {
}
describe('RuntimeAcpBackend', () => {
it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => {
vi.useFakeTimers()
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
try {
await open(fixture)
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
let settled = false
const starting = invoke(fixture, 'runtime/startPrompt', {
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1', prompt: [{ type: 'text', text: 'test' }]
}).catch(() => undefined).finally(() => { settled = true })
await vi.advanceTimersByTimeAsync(1)
expect(fixture.process.writes).toHaveLength(1)
let disposalError: unknown
const disposal = action === 'dispose' ? fixture.backend.dispose().catch((error: unknown) => { disposalError = error }) : undefined
await vi.advanceTimersByTimeAsync(action === 'watchdog' ? 121000 : 10000)
const observed = { settled, stops: fixture.process.stops.length, disposalError }
const initialize = JSON.parse(Buffer.from(fixture.process.writes[0]!).toString())
await fixture.process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: initialize.id, error: { code: -32603, message: 'release fixture' } })}\n`)
await Promise.all([starting, disposal])
await vi.advanceTimersByTimeAsync(100)
expect(observed).toEqual({ settled: true, stops: 1, disposalError: undefined })
expect(fixture.process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()).method)).toEqual(['initialize'])
} finally {
vi.useRealTimers()
await fixture.backend.dispose().catch(() => undefined)
}
})
it.each(['cancel', 'deadline'])('keeps a shared peer responsive during native startup and %s', async reason => {
const fixture = await ownedHarness(true)
let releaseStartup: (() => Promise<void>) | undefined
try {
await fixture.start()
const process = fixture.processes[0]!
const originalWrite = process.onWrite!
process.onWrite = payload => {
const request = JSON.parse(Buffer.from(payload).toString())
if (request.method === 'session/new') {
releaseStartup = () => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'late-session' } })}\n`)
} else originalWrite(payload)
}
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
vi.useFakeTimers()
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
operationId: 'starting-request', requestId: 'starting-request',
deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
let startSettled = false
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] })
.catch(() => undefined).finally(() => { startSettled = true })
await vi.advanceTimersByTimeAsync(1)
expect(releaseStartup).toBeDefined()
let peerSettled = false
const peer = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peerSettled = true })
let cancelled: Promise<unknown> | undefined
if (reason === 'cancel') cancelled = invoke(fixture, 'runtime/escalateCancellation', {
bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'opening', reason: 'requested'
})
await vi.advanceTimersByTimeAsync(200)
const observed = { peerSettled, startSettled, stops: process.stops.length }
// Release the fake native response even on the old implementation.
await releaseStartup!()
await vi.advanceTimersByTimeAsync(5000)
await Promise.all([starting, peer, cancelled])
expect(observed).toEqual({ peerSettled: true, startSettled: true, stops: 0 })
expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running')
const writes = process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()))
expect(writes.filter(request => request.method === 'session/prompt')).toHaveLength(1)
expect(writes).toContainEqual(expect.objectContaining({ method: 'session/close', params: { sessionId: 'late-session' } }))
await fixture.prompts[0]!()
await vi.advanceTimersByTimeAsync(1)
} finally {
await releaseStartup?.()
await fixture.prompts[0]?.()
vi.useRealTimers()
await fixture.cleanup()
}
})
it.each([false, true])('commits completion before idle cleanup and preserves it on cleanup failure (shared=%s)', async shared => {
for (const failure of ['stop', 'reconcile'] as const) {
const fixture = await ownedHarness(shared)
+18 -10
View File
@@ -440,7 +440,8 @@ export class RuntimeAcpBackend {
'runtime/preparePrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#prepare(params, context)),
'runtime/startPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context))
.then(({ started }) => started),
'runtime/attachPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
'runtime/respondToQuestion': (params, context) =>
@@ -1441,7 +1442,7 @@ export class RuntimeAcpBackend {
return completion
}
async #startOwnedPrompt(
#startOwnedPrompt(
params: unknown,
context: ProtocolMethodContext
) {
@@ -1464,7 +1465,7 @@ export class RuntimeAcpBackend {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
return existing
return { started: Promise.resolve(existing) }
}
if (
this.#options.semanticPrompts === undefined ||
@@ -1523,13 +1524,20 @@ export class RuntimeAcpBackend {
binding.poisoned ? 'outcome-unknown' : status
})
}
const result = await binding.ownedAcp.start(request)
prepared.ownedPromptStarted = true
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
return result
const owner = binding.ownedAcp
// Native initialization must not hold the global control queue: cancellation,
// watchdogs and other bindings still need to make progress while it waits.
const started = owner.start(request).then(result => {
if (binding.ownedAcp === owner && binding.activeOperationId === request.operationId) {
prepared.ownedPromptStarted = true
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
}
return result
})
return { started }
}
#attachOwnedPrompt(
+88 -1
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import {
@@ -263,6 +264,16 @@ function runClient(events: Record<string, unknown>[]) {
};
}
function stalledFetch(request: Request): Promise<Response> {
return new Promise<Response>((_resolve, reject) => {
// Like a live fetch, retain the Request until cancellation. Keeping only its
// signal lets Undici's weakly referenced controller disappear during GC.
const abort = (): void => reject(request.signal.reason);
if (request.signal.aborted) abort();
else request.signal.addEventListener("abort", abort, { once: true });
});
}
function embeddedRuntime(
client: ReturnType<typeof createOpencodeClient>,
overrides: Partial<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {},
@@ -3323,6 +3334,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
}
});
it("retains a stalled SDK fetch until abort even under garbage collection", () => {
const output = execFileSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", `
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { setImmediate as turn } from 'node:timers/promises';
const stalledFetch = ${stalledFetch.toString()};
const source = new AbortController();
let requestSignal, requestRef, result;
const sdk = createOpencodeClient({ baseUrl: 'http://127.0.0.1:1', throwOnError: false,
fetch: request => {
requestSignal = request.signal;
requestRef = new WeakRef(request);
return stalledFetch(request);
}
});
const operation = sdk.mcp.disconnect({ name: 'gc-fixture' }, { signal: source.signal });
operation.then(value => { result = value; });
while (!requestSignal) await turn();
for (let i = 0; i < 10; i++) { await turn(); gc(); await turn(); }
const requestRetained = Boolean(requestRef.deref());
source.abort(new Error('controlled deadline'));
await turn();
console.log(JSON.stringify({ requestRetained, sourceAborted: source.signal.aborted,
requestAborted: requestSignal.aborted, sdkSettledWithError: Boolean(result?.error) }));
`], { encoding: "utf8", timeout: 4_000 });
expect(JSON.parse(output)).toEqual({
requestRetained: true, sourceAborted: true, requestAborted: true, sdkSettledWithError: true,
});
});
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
"handles installed SDK fetch results: %s", async (scenario) => {
const setup = runClient([
@@ -3344,7 +3384,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
order.push("disconnect");
signals.push(request.signal);
if (scenario === "cleanup-transport") throw failure;
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
if (scenario === "cleanup-timeout") return stalledFetch(request);
return Response.json(scenario !== "cleanup-false");
}
order.push("add");
@@ -4680,6 +4720,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it.each(["rejection", "error-result", "timeout"])("settles failed submission with silent SSE: %s", async (failure) => {
vi.useFakeTimers();
const setup = runClient([]);
const controller = new AbortController();
let readerClosed = false;
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
stream: (async function* () {
try {
yield* [];
await new Promise<void>((resolve) => {
if (options?.signal?.aborted) resolve();
else options?.signal?.addEventListener("abort", () => resolve(), { once: true });
});
} finally { readerClosed = true; }
})(),
}) as never);
vi.mocked(setup.session.promptAsync).mockImplementation(async () => {
if (failure === "timeout") return await new Promise(() => {});
if (failure === "error-result") return { error: { data: { message: "submission rejected" } } } as never;
throw new Error("submission rejected");
});
const runtime = new OpenCodeRuntime(options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), {
createClient: () => setup.client, controlRequestTimeoutMs: 10,
});
let settled = false;
let error: unknown;
const pending = (async () => {
for await (const event of runtime.run({ requestId: "silent", conversationId: "silent", prompt: "test" }, controller.signal)) { void event; }
})().catch((failure: unknown) => { error = failure; }).finally(() => { settled = true; });
try {
await vi.advanceTimersByTimeAsync(100);
expect(settled).toBe(true);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(failure === "timeout" ? "OpenCode 提交请求超时" : "submission rejected");
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
expect(setup.session.abort).toHaveBeenCalledOnce();
expect(readerClosed).toBe(true);
expect(controller.signal.aborted).toBe(false);
} finally {
controller.abort();
await vi.advanceTimersByTimeAsync(20);
await pending;
await runtime.dispose();
vi.useRealTimers();
}
});
it("surfaces a rejected async prompt instead of reporting success", async () => {
const { client, session } = runClient([
{
+11 -2
View File
@@ -2559,6 +2559,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
let runFailed = false;
let submissionFailure: { error: unknown } | undefined;
try {
const promptText = promptWithUntrustedConversationHistory(
request,
@@ -2643,7 +2644,15 @@ export class OpenCodeRuntime implements AgentRuntime {
),
signal,
);
prompt.catch(() => undefined);
void prompt.then((result) => {
if (result.error) {
throw new Error(opencodeErrorMessage(result.error, "OpenCode 提交请求失败"));
}
}).catch((error: unknown) => {
submissionFailure = { error };
// Wake a silent SSE reader without cancelling any peer subscription.
subscriptionController.abort(error);
});
const repliedPermissionIds = new Set<string>();
const ownedSessions = new Set([sessionId]);
@@ -3174,7 +3183,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
throw error;
throw submissionFailure?.error ?? error;
} finally {
signal.removeEventListener("abort", abortSession);
// Keep the conversation locked until its session-wide abort settles.
+3 -3
View File
@@ -12378,11 +12378,11 @@ export class AssistantDatabase {
return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) }
}
async supervisionCandidates(request: SupervisionRunRequest): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
async supervisionCandidates(request: SupervisionRunRequest, batch?: { projectId: string; crossProject: boolean }): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
const reader = this.readonlyQueryReader()
if (reader) return reader.call('reviewCandidates', [request])
if (reader) return reader.call('reviewCandidates', [request, batch])
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
return this.listSupervisionCandidates(request)
return this.listSupervisionCandidates(request, batch)
}
supervisionSuggestions(): SupervisionSuggestionStore {
@@ -211,7 +211,17 @@ function fixture(content = 'Atlas and Beacon are separate projects.') {
const service = (responseKiB = 1024) => createProductionSupervisorService(db, async () => ({ supervisorModelConcurrency: 1,
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB } }),
async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), pool)
return { db, request, respond, service }
// Project ownership comes from timeline events, not the scope that first saved an entity.
const associateCandidates = () => {
const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase()
const id = randomUUID()
sql.prepare(`INSERT INTO supervision_events (id, story_line_id, result_id, occurred_at, title, description, event_type, project_id)
SELECT ?, story_lines.id, supervision_results.id, ?, 'Seed ownership', '', 'discussion', ?
FROM story_lines, supervision_results LIMIT 1`).run(id, request.timeRange.from, db.listProjects()[0]!.id)
sql.prepare('INSERT INTO supervision_event_entities (event_id, entity_id, change_type) SELECT ?, id, ? FROM supervision_entities').run(id, 'associated')
sql.prepare('INSERT INTO supervision_story_assigned VALUES (?)').run(id)
}
return { db, request, respond, service, associateCandidates }
}
it('records supervisor model usage under a hidden supervision task', async () => {
@@ -237,6 +247,7 @@ it('production factory normalizes two absent identities, then reuses exact candi
const first = await f.service().run(f.request)
expect(first.output.entities).toHaveLength(2)
for (const entity of first.output.entities) expect(entity).not.toHaveProperty('persistedId')
f.associateCandidates()
const candidates = f.db.listSupervisionCandidates(f.request)
expect(candidates).toHaveLength(2)
expect(f.respond.mock.calls[0]![0]).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef')
@@ -256,6 +267,7 @@ it('retains candidate mappings across leaf batches, failed merge and resume with
const f = fixture('Atlas and Beacon. '.repeat(100))
f.db.saveSupervisionResult({ request: f.request, evidence: [], output: { ...empty,
entities: ['Atlas', 'Beacon'].map(label => ({ id: label, label, description: label, sourceReferenceIds: [] })) } })
f.associateCandidates()
const candidates = f.db.listSupervisionCandidates(f.request)
const original = f.respond.getMockImplementation()!
let failMerge = true
@@ -346,6 +358,7 @@ it('reuses actual legacy text-key shapes through strict UUID aliases and preserv
sql.prepare(`INSERT INTO supervision_entities (id, story_line_id, canonical_label, description, confirmation_state, updated_at, source_reference_ids_json)
VALUES (?, ?, ?, 'Human description', 'confirmed', ?, '[]')`).run(id, String(story.id), id, new Date().toISOString())
}
f.associateCandidates()
const candidates = f.db.listSupervisionCandidates(f.request)
const legacy = candidates.filter(candidate => candidate.storageId)
expect(legacy).toHaveLength(2)
+1 -1
View File
@@ -126,7 +126,7 @@ export function createProductionSupervisorService(
background: request => database.reviewBackground(request.scope),
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
candidates: request => database.supervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
candidates: (request, batch) => database.supervisionCandidates(request, batch), save: async result => database.saveSupervisionResult(result)
}, { database: () => database.supervisionReviewStore(),
initialize: (runId, state, signal) => database.initializeSupervisionReview(runId, state, signal),
resume: (runId, signal) => database.resumeSupervisionReview(runId, signal),
@@ -247,3 +247,37 @@ it('runs after publication inside the review and records failure without failing
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Timeline'])
await expect(service.run(request)).resolves.toMatchObject({ status: 'no_change', coverage: { stories: { status: 'completed', calls: 0 } } })
})
it.each(['remove', 'merge'] as const)('rejects an assignment whose selected story was changed by %s while awaiting the model', async action => {
const f = await fixture()
f.publish(f.a.id, ['First', 'Second'])
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Alpha' }, { key: 'new_2', level: 'feature', name: 'Beta' }],
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_2' }] }), { kind: 'global' }, options)
const [alpha, beta] = f.stories.list({ kind: 'global' })
f.publish(f.a.id, ['New evidence'], 22)
const model = async () => {
f.stories.act(action === 'remove' ? { action, storyId: alpha!.id } : { action, storyId: alpha!.id, intoId: beta!.id })
return JSON.stringify({ stories: [{ key: 'new_1', level: 'feature', name: 'Must roll back' }], assignments: [{ event: 'e_1', story: 'story_1' }] })
}
await expect(assignStories(f.stories, model, { kind: 'global' }, options)).rejects.toThrow('Story changed')
expect(f.stories.pending({ kind: 'global' })).toHaveLength(1)
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Beta'])
expect(f.sql.prepare("SELECT COUNT(*) AS n FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE s.status != 'current'").get()!.n).toBe(0)
await assignStories(f.stories, answer({ assignments: [{ event: 'e_1', story: 'story_1' }] }), { kind: 'global' }, { ...options, threadEvents: 100 })
expect(f.stories.pending({ kind: 'global' })).toEqual([])
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
})
it.each(['feature', 'thread'] as const)('rejects a split after its %s was removed without leaving new threads', async removed => {
const f = await fixture()
f.publish(f.a.id, ['One', 'Two', 'Three', 'Four'])
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Feature' }, { key: 'new_2', level: 'thread', name: 'Thread', parent: 'new_1' }],
assignments: [1, 2, 3, 4].map(n => ({ event: `e_${n}`, story: n === 4 ? 'new_2' : 'new_1' })) }), { kind: 'global' }, { ...options, threadEvents: 4 })
const [feature, thread] = f.stories.list({ kind: 'global' })
const input = f.stories.splitInput(feature!.id, 8000)
f.stories.act({ action: 'remove', storyId: removed === 'feature' ? feature!.id : thread!.id })
const before = f.stories.list({ kind: 'global' })
expect(() => f.stories.split(input, { threads: [], moves: [{ event: 'e_1', thread: 'thread_1' }] }, 4)).toThrow('Story changed')
expect(f.stories.list({ kind: 'global' })).toEqual(before)
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
})
+15
View File
@@ -236,6 +236,7 @@ export class SupervisionStoryStore {
VALUES (?, ?, ?, 'thread', ?, ?, ?, ?)`).run(id, String(input.feature.project_id), featureId, thread.name, thread.description, now, now)
ids.set(thread.key, id); existing.set(name, id); created++
}
this.assertCurrent([featureId, ...output.moves.flatMap(item => ids.get(item.thread) ?? [])])
const move = this.db.prepare('UPDATE supervision_event_stories SET story_id = ? WHERE event_id = ? AND story_id = ? AND is_primary = 1 AND user_set = 0')
for (const item of output.moves) {
const id = ids.get(item.thread)
@@ -252,6 +253,14 @@ export class SupervisionStoryStore {
.map(row => `${row.parent}|${nameKey(String(row.name))}`))
}
/** Check model-selected destinations under the caller's write transaction. */
private assertCurrent(ids: string[]): void {
const unique = [...new Set(ids)]
const count = this.db.prepare(`SELECT COUNT(*) AS n FROM supervision_stories
WHERE id IN (SELECT value FROM json_each(?)) AND status = 'current'`).get(JSON.stringify(unique))!.n
if (Number(count) !== unique.length) throw new Error('Story changed while organizing; retry with current stories')
}
/** Applies one validated model answer for one project chunk in a single transaction. */
apply(projectId: string, events: PendingEvent[], candidates: Row[], output: z.infer<typeof outputSchema>, options: Pick<StoryAssignmentOptions, 'crossProject' | 'threadEvents'>, others: Row[] = []): Omit<StoryAssignmentResult, 'calls' | 'split'> {
const eventRefs = new Map(events.map((event, index) => [`e_${index + 1}`, event]))
@@ -336,6 +345,12 @@ export class SupervisionStoryStore {
ids.set(story.key, id)
created++
}
const selected = [
...output.assignments.flatMap(item => [item.story, ...(item.related ?? [])]),
...output.stories.map(item => item.parent), ...output.concluded.map(item => item.story),
...output.links.flatMap(item => [item.cross, item.feature])
]
this.assertCurrent(selected.flatMap(ref => ref ? ids.get(ref) ?? (otherRefs.has(ref) ? String(otherRefs.get(ref)!.id) : []) : []))
const link = this.db.prepare('INSERT OR IGNORE INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, ?)')
const mark = this.db.prepare('INSERT OR IGNORE INTO supervision_story_assigned (event_id) VALUES (?)')
let assigned = 0, unassigned = 0
+3 -3
View File
@@ -73,9 +73,9 @@ export function supersedeReextractedEvents(db: DatabaseSync, resultId: string):
*/
export function timelineCandidates(db: DatabaseSync, projectId: string, crossProject: boolean): Array<{ id: string; label: string; description: string }> {
return db.prepare(`SELECT e.id, e.canonical_label AS label, e.description FROM supervision_entities e
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR EXISTS (
SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR e.id IN (
SELECT ee.entity_id FROM supervision_events ev JOIN supervision_event_entities ee ON ee.event_id = ev.id
WHERE ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
ORDER BY e.updated_at DESC, e.id LIMIT 100`).all(crossProject ? 1 : 0, projectId) as Array<{ id: string; label: string; description: string }>
}
@@ -157,6 +157,76 @@ it('publishes full worker-selected coverage atomically and skips it on the next
expect(calls).toBe(before)
})
it.each([false, true])('production worker preserves batch candidate isolation with crossProject=%s', async crossProject => {
const f = await fixture()
const empty = { summary: 'Seed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
const projects = f.db.listProjects()
for (const project of projects) f.db.saveSupervisionResult({
request: { ...f.state.request, scope: project.id === f.project.id ? { kind: 'global' } : { kind: 'projects', projectIds: [project.id] } },
evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(), title: 'Seed', content: 'Seed',
occurredAt: f.state.request.timeRange.from, locator: { projectId: project.id } }],
output: { ...empty, entities: [{ id: 'entity', label: project.id, description: 'Owned knowledge', sourceReferenceIds: ['s'] }],
events: [{ title: 'Seed', description: '', occurredAt: f.state.request.timeRange.from, eventType: 'discussion', entityIds: ['entity'], sourceReferenceIds: ['s'] }] }
})
f.sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events')
const pool = new SupervisionModelPool()
cleanup.push(async () => pool.dispose())
const seen = new Set<string>()
const runtime = { async *run(input: { prompt: string }) {
if (!input.prompt.includes('These inputs are navigation summaries')) {
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ locator: { projectId: string } }>
const projectId = evidence[0]!.locator.projectId
const candidates = JSON.parse(input.prompt.split('KNOWN ENTITIES:\n\n')[1]!.split('\n\nPREVIOUS SUMMARY')[0]!) as Array<{ label: string }>
expect(candidates.map(item => item.label).sort()).toEqual((crossProject ? projects.map(p => p.id) : [projectId]).sort())
seen.add(projectId)
}
yield { type: 'text', delta: JSON.stringify(empty) }
yield { type: 'done' }
}, async dispose() {} } as unknown as AgentRuntime
const service = createProductionSupervisorService(f.db, async () => ({ supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject }) }), async () => runtime, pool)
await expect(service.run(f.state.request)).resolves.toMatchObject({ status: 'completed', coverage: { sources: 422 } })
expect([...seen].sort()).toEqual(projects.map(p => p.id).sort())
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_entities').get()!.n).toBe(2)
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
})
it.each(['paused', 'cancelled'] as const)('honors %s during the final production candidate read before publishing', async action => {
const f = await fixture()
const pool = new SupervisionModelPool()
cleanup.push(async () => pool.dispose())
const runtime = { async *run() {
yield { type: 'text', delta: JSON.stringify({ summary: 'Saved leaf', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
yield { type: 'done' }
}, async dispose() {} } as unknown as AgentRuntime
const service = createProductionSupervisorService(f.db, async () => ({}), async () => runtime, pool)
const candidates = f.db.supervisionCandidates.bind(f.db)
let stopped = false
let cancellation: Promise<void> | undefined
vi.spyOn(f.db, 'supervisionCandidates').mockImplementation(async (...args) => {
const result = await candidates(...args)
const runId = service.execution().runId!
if (!stopped && f.db.supervisionReviewStore().progress(runId).phase === 'saving') {
stopped = true
if (action === 'paused') service.pause(runId)
else cancellation = service.cancel(runId)
}
return result
})
const result = await service.run(f.state.request)
await cancellation
expect(stopped).toBe(true)
expect(result.status).toBe(action)
expect(f.db.listSupervisionResults()).toEqual([])
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
const saved = f.db.supervisionReviewStore().batches(result.runId!, 1000)
expect(saved.length).toBeGreaterThan(0)
if (action === 'paused') {
await expect(service.resume(result.runId!)).resolves.toMatchObject({ status: 'completed' })
expect(f.db.supervisionReviewStore().batches(result.runId!, 1000)).toEqual(saved)
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(422)
}
})
it.each(['crash', 'close'] as const)('settles writer %s before releasing pending work and can rebuild after reopening', async action => {
const f = await fixture()
const id = f.db.startSupervisionRun(f.state.request)
+1
View File
@@ -487,6 +487,7 @@ export class SupervisorService {
for (const batch of saved) savedProjects.add(batch.projectId)
}
for (const projectId of savedProjects) await candidatesFor(projectId)
controller.signal.throwIfAborted()
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { createServer } from 'node:http'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ChannelService } from './channel-service'
import { SqliteChannelOutbox } from './sqlite-channel-state'
import {
DingTalkChannelDriver,
createOfficialDingTalkTransportFactory
@@ -46,6 +50,65 @@ function envelope(
}
describe('DingTalkChannelDriver', () => {
it('retries an HTTP 200 business failure through the production transport and SQLite outbox', async () => {
const requests: string[] = []
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
requests.push(body)
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify(requests.length === 1
? { errcode: 130101, errmsg: 'synthetic rate limit' }
: { errcode: 0, errmsg: 'ok' }))
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const database = new AssistantDatabase(':memory:')
database.initialize('C:\\Workspace')
const outbox = new SqliteChannelOutbox(database)
let receive!: (message: { headers: { messageId: string }; data: string }) => void
const driver = new DingTalkChannelDriver({
clientId: 'client-id', clientSecret: 'synthetic-secret', allowedSenderIds: ['user-1'],
transportFactory: createOfficialDingTalkTransportFactory({
clientFactory: async () => ({
registerCallbackListener: (_topic, listener) => { receive = listener },
socketCallBackResponse: () => undefined,
connect: async () => undefined, disconnect: () => undefined
}),
fetchImpl: (_input, init) => fetch(`http://127.0.0.1:${address.port}/reply`, init)
})
})
const executor = vi.fn(async () => ({ status: 'completed', output: 'synthetic reply' }))
const failure = vi.fn()
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['user-1'], allowGroupMessages: true, outbox, onDeliveryFailure: failure
})
try {
await service.start()
receive(envelope())
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 })
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([]))
expect(requests[1]).toBe(requests[0])
expect(executor).toHaveBeenCalledOnce()
} finally {
await service.stop()
database.close()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
it.each(['', '{}', 'null', 'not JSON', JSON.stringify({ errcode: 0, padding: 'x'.repeat(65_536) })])(
'rejects invalid or oversized successful HTTP responses (%#)', async (body) => {
const transport = await createOfficialDingTalkTransportFactory({
fetchImpl: async () => new Response(body)
}).create({ clientId: 'synthetic', clientSecret: 'synthetic' })
await expect(transport.replyText(SESSION_WEBHOOK, 'reply')).rejects.toThrow()
}
)
it('adapts group text and consumes only the issued reply context', async () => {
const transport = new FakeTransport()
const factory: DingTalkTransportFactory = {
@@ -133,7 +196,7 @@ describe('DingTalkChannelDriver', () => {
connect: vi.fn(async () => undefined),
disconnect: vi.fn()
}
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }))
const fetchImpl = vi.fn(async () => Response.json({ errcode: 0, errmsg: 'ok' }))
const factory = createOfficialDingTalkTransportFactory({
clientFactory: async (credentials) => {
expect(credentials).toEqual({
+17 -15
View File
@@ -3,6 +3,7 @@ import type {
ChannelResultMessage
} from '../../shared/channel-contracts'
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
import { readBoundedResponseText } from '../../shared/node/bounded-response'
import {
DingTalkDriver,
type DingTalkStreamEnvelope,
@@ -133,6 +134,7 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
const timeout = setTimeout(() => {
controller.abort(new Error('钉钉回复超时'))
}, REPLY_TIMEOUT_MS)
let result: unknown
try {
const response = await this.fetchImpl(sessionWebhook, {
method: 'POST',
@@ -144,22 +146,26 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
redirect: 'error',
signal: controller.signal
})
const responseLength = Number(
response.headers.get('content-length') ?? '0'
)
if (
!response.ok ||
!Number.isFinite(responseLength) ||
responseLength > MAXIMUM_RESPONSE_BYTES
) {
if (!response.ok) {
await response.body?.cancel()
throw new Error('钉钉回复请求失败')
}
await response.body?.cancel()
result = JSON.parse(await readBoundedResponseText(response, {
maxBytes: MAXIMUM_RESPONSE_BYTES,
tooLargeMessage: '钉钉回复响应过大',
truncatedMessage: '钉钉回复响应不完整'
}))
} catch {
throw new Error('钉钉回复请求失败')
} finally {
clearTimeout(timeout)
}
if (typeof result !== 'object' || result === null || !('errcode' in result) || result.errcode !== 0) {
const code = typeof result === 'object' && result !== null && 'errcode' in result &&
typeof result.errcode === 'number' && Number.isSafeInteger(result.errcode)
? ` (${result.errcode})` : ''
throw new Error(`钉钉回复请求失败${code}`)
}
}
}
@@ -245,12 +251,8 @@ export class DingTalkChannelDriver implements ChannelDriver {
throw new Error('钉钉回复上下文无效或已过期')
}
try {
signal.throwIfAborted()
await this.driver.reply(record.context, resultText(message))
} catch {
throw new Error('钉钉消息回复失败')
}
signal.throwIfAborted()
await this.driver.reply(record.context, resultText(message))
if (!message.attachments?.length) {
this.replyContexts.delete(message.eventId)
}
+46 -27
View File
@@ -36,6 +36,9 @@ import type {
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { ChannelSettingsStore } from './channels/channel-settings-store'
import { ChannelService } from './channels/channel-service'
import { SqliteChannelOutbox } from './channels/sqlite-channel-state'
import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver'
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
@@ -12583,8 +12586,20 @@ describe('registerIpcHandlers agent terminal state', () => {
throw new Error('Expected channel executor')
}
await expect(
executor(
const outboxDatabase = new AssistantDatabase(':memory:')
outboxDatabase.initialize('C:\\Workspace')
const outbox = new SqliteChannelOutbox(outboxDatabase)
let inbound!: ChannelInboundHandler
const send = vi.fn<ChannelDriver['send']>(async () => undefined)
const service = new ChannelService({
channel: 'wecom',
start: (handler) => { inbound = handler },
send,
stop: () => undefined
}, executor, { allowedSenderIds: ['user-1'], outbox })
try {
await service.start()
await inbound(
{
channel: 'wecom',
eventId: 'event-generated-image',
@@ -12594,34 +12609,38 @@ describe('registerIpcHandlers agent terminal state', () => {
text: '生成结果图',
mentioned: false
},
new AbortController().signal
() => undefined
)
).resolves.toMatchObject({
status: 'completed',
attachments: [
{
name: '结果图.png',
mimeType: 'image/png',
size: image.byteLength,
kind: 'image',
dataBase64: image.toString('base64')
}
],
artifactIds: [
'00000000-0000-4000-8000-000000000499'
]
})
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenLastCalledWith(
expect.objectContaining({
role: 'assistant',
artifactIds: [
'00000000-0000-4000-8000-000000000499'
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
expect(send.mock.calls[0]?.[0]).toMatchObject({
status: 'completed',
attachments: [
{
name: '结果图.png',
mimeType: 'image/png',
size: image.byteLength,
kind: 'image',
dataBase64: image.toString('base64')
}
]
})
)
await harness.dispose()
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds')
expect(outbox.listUndelivered()).toEqual([])
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenLastCalledWith(
expect.objectContaining({
role: 'assistant',
artifactIds: [
'00000000-0000-4000-8000-000000000499'
]
})
)
} finally {
await service.stop()
outboxDatabase.close()
await harness.dispose()
}
})
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
+2 -1
View File
@@ -3056,7 +3056,8 @@ export function registerIpcHandlers(
status:
result.status === 'completed' ? 'completed' : 'failed'
})
return result
const { status, output, error, attachments } = result
return { status, output, error, attachments }
} finally {
releaseRemoteContexts()
}
+2 -1
View File
@@ -78,7 +78,8 @@ if (kind === 'checkpoint') {
const scope = (request as ReviewState['request']).scope
return database.readSnapshot(() => ({ summary: database.reviewSummary(scope, 'supervisor'), background: database.reviewBackground(scope) }))
},
reviewCandidates: ([request]) => database.listSupervisionCandidates(request as ReviewState['request'])
reviewCandidates: ([request, batch]) => database.listSupervisionCandidates(request as ReviewState['request'],
batch as Parameters<AssistantDatabase['listSupervisionCandidates']>[1])
}
}
@@ -178,6 +178,39 @@ afterEach(async () => {
})
describe('ChannelSettingsSection', () => {
it.each(['error', 'stopped', 'running', 'starting', 'disabled'] as const)(
'only reapplies unchanged enabled Telegram settings when pushed state is %s and needs recovery',
async (state) => {
const configured: ChannelSettingsSnapshot = {
...snapshot,
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted', status: { state: 'running' } }
}
let receive!: (change: ChannelRuntimeStatusChange) => void
const apply = vi.fn(async () => configured)
const onNotify = vi.fn()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: { ...bindingApi(), getSnapshot: async () => configured, apply,
onStatusChanged: (listener: typeof receive) => { receive = listener; return () => undefined } },
settings: settingsApi(), projects: { update: vi.fn(async () => projects[0]) }
} as unknown as DesktopApi
})
renderChannelSettings({ initialChannel: 'telegram', onNotify })
await screen.findByLabelText('TelegramBot Token')
act(() => receive({ channel: 'telegram', status: { state } }))
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => expect(onNotify).toHaveBeenCalled())
if (state === 'error' || state === 'stopped') {
expect(apply).toHaveBeenCalledWith({ telegram: {
enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
} })
} else {
expect(apply).not.toHaveBeenCalled()
}
}
)
it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
let receive!: (change: ChannelRuntimeStatusChange) => void
const unsubscribe = vi.fn()
+4 -1
View File
@@ -1002,6 +1002,9 @@ export function ChannelSettingsSection({
)
return
}
const telegramState = (statusStore.getState().telegram ?? snapshot.telegram.status).state
const restartTelegram = drafts.telegram.enabled &&
(telegramState === 'error' || telegramState === 'stopped')
const input: ChannelSettingsApply = {
...(weixinEnabled === snapshot.weixin.enabled
? {}
@@ -1015,7 +1018,7 @@ export function ChannelSettingsSection({
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
: {}),
...(!snapshot.telegram.readOnly &&
channelDraftChanged('telegram', drafts.telegram, snapshot)
(channelDraftChanged('telegram', drafts.telegram, snapshot) || restartTelegram)
? { telegram: inputFor('telegram', drafts.telegram) }
: {})
}
+85
View File
@@ -182,6 +182,91 @@ afterEach(() => {
})
describe('ChatHistoryPane windowing', () => {
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
const tool = { callId: 'read', name: 'read', summary: 'Read file', state: 'completed' as const, input: '{"path":"file"}', output: 'File contents' }
Object.assign(messages[0]!, { tools: [tool], subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert',
routingMode: 'native', state: 'completed', progress: [{ id: 'child-tool', type: 'tool', tool }] }] })
const { container, chat } = setup(messages)
measure()
const visit = async (index: number) => {
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
await frame()
measure()
}
const disclosure = (index: number) => container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index}"] details`)!
await visit(0)
const original = disclosure(0)
act(() => { original.open = true; fireEvent(original, new Event('toggle')) })
const toggle = (selector: string, open: boolean) => {
const element = container.querySelector<HTMLDetailsElement>(`[data-message-id="m0"] ${selector}`)!
act(() => { element.open = open; fireEvent(element, new Event('toggle')) })
}
toggle('.subagent-status-card', true)
toggle('.subagent-status-card .tool-execution', true)
toggle('.subagent-status-card .tool-execution__input', true)
// Collapsing a subagent unmounts its children even before row eviction.
toggle('.subagent-status-card', false)
toggle('.subagent-status-card', true)
expect(container.querySelector<HTMLDetailsElement>('.subagent-status-card .tool-execution__input')!.open).toBe(true)
for (let index = 1; index <= 12; index++) {
await visit(index * 100)
const details = disclosure(index * 100)
act(() => { details.open = true; fireEvent(details, new Event('toggle')) })
expect(renderedIds(container).length).toBeLessThanOrEqual(maxRenderedMessageCount + 8)
}
expect(original.isConnected).toBe(false)
await visit(0)
expect(disclosure(0)).not.toBe(original)
expect(disclosure(0).open).toBe(true)
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card')!.open).toBe(true)
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card .tool-execution__input')!.open).toBe(true)
// The parent and child deliberately share a tool call ID, but not state.
expect([...container.querySelectorAll<HTMLDetailsElement>('[data-message-id="m0"] .tool-execution')]
.find(element => !element.closest('.subagent-status-card'))!.open).toBe(false)
act(() => { disclosure(0).open = false; fireEvent(disclosure(0), new Event('toggle')) })
await visit(1500)
await visit(0)
expect(disclosure(0).open).toBe(false)
})
it.each(['legacy', 'blocks'] as const)('preserves %s streaming reasoning defaults and manual choices through eviction and completion', async representation => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
messages[0] = { ...messages[0]!, state: 'streaming' }
if (representation === 'blocks') Object.assign(messages[0]!, { blocks: [{ id: 'reason', type: 'reasoning', content: 'Reasoning' }] })
const { container, chat, props, rerender } = setup(messages)
measure()
const visit = async (index: number) => {
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
await frame(); measure()
}
const details = () => container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .message-reasoning')!
await visit(0)
expect(details().open).toBe(true)
act(() => { details().open = false; fireEvent(details(), new Event('toggle')) })
for (let index = 1; index <= 10; index++) {
await visit(index * 100)
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
}
expect(details()).toBeNull()
await visit(0)
expect(details().open).toBe(false)
act(() => { details().open = true; fireEvent(details(), new Event('toggle')) })
for (let index = 1; index <= 10; index++) {
await visit(index * 100)
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
}
expect(details()).toBeNull()
// Completion keeps the existing auto-close semantics rather than restoring
// the streaming expansion on the next mount.
const completed = messages.map((message, index) => index === 0 ? { ...message, state: 'complete' as const } : message)
rerender(<ChatHistoryPane {...props} conversation={conversationOf(completed)} />)
await visit(0)
expect(details().open).toBe(false)
})
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => {
const messages = makeMessages(2_000)
const { container, chat } = setup(messages)
+7 -7
View File
@@ -18,6 +18,7 @@ import type {
import type { ImageOperation } from "../../shared/image-generation-contracts";
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
import { isUnusedConversation, type Conversation } from "./chat-conversation";
import { MessageExpansionProvider } from "./message-expansion";
import {
buildSegments,
maxRenderedMessageCount,
@@ -246,8 +247,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
// Rows kept mounted outside the range: the focused row, the first message
// after the final "load earlier" step.
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
// Rows the user changed (toggled a <details>, typed into a question card):
// their local state would be lost if they unmounted. Bounded, oldest out.
// Keep recent interactions warm (copy feedback, previews, typed answers).
// Disclosure choices also survive eviction in MessageExpansionProvider.
const [interactedMessageIds, setInteractedMessageIds] = useState<readonly string[]>([]);
// Rows holding the start and end of a non-empty text selection.
const [selectionMessageIds, setSelectionMessageIds] = useState<readonly string[]>([]);
@@ -544,9 +545,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
return () => cancelAnimationFrame(frame);
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
// Rows the user interacts with keep their local state (open details, typed
// question answers, "copied" feedback, Mermaid toggles) as they did when
// every loaded row stayed mounted.
// Keep a bounded set of recently interacted rows mounted. Disclosure state
// lives outside the rows, while pending questions are independently pinned.
useEffect(() => {
const list = listRef.current;
if (!active || !list) return;
@@ -1065,7 +1065,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
};
return (
<div
<MessageExpansionProvider messages={messages} messageIndexes={messageIndexById}><div
aria-hidden={active ? undefined : "true"}
className="chat-history-pane"
data-active={active ? "true" : "false"}
@@ -1153,6 +1153,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
</div></MessageExpansionProvider>
);
});
+21 -9
View File
@@ -1,5 +1,6 @@
import { AttachmentResultButton } from './AttachmentResultButton'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { MessageExpansionScope, useMessageExpansion } from './message-expansion'
import {
Bot,
Check,
@@ -167,14 +168,17 @@ function formatAttachmentSize(size: number): string {
}
function MessageReasoning({
expansionId = 'reasoning',
content,
streaming
}: {
expansionId?: string
content: string
streaming: boolean
}): React.JSX.Element {
const { t } = useTranslation('app')
const contentRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useMessageExpansion(expansionId, streaming)
useEffect(() => {
if (!streaming || !contentRef.current) {
@@ -187,7 +191,7 @@ function MessageReasoning({
}, [content, streaming])
return (
<details className="message-reasoning" open={streaming}>
<details className="message-reasoning" open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}>
<summary>
{streaming
? t('chat.reasoning.streaming')
@@ -239,12 +243,15 @@ const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false
)
})
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pick<
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, ...tool }: Pick<
ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error'
> & {
expansionId: string
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useMessageExpansion(`tool:${expansionId}`)
const [inputExpanded, setInputExpanded] = useMessageExpansion(`tool-input:${expansionId}`)
const summary = tool.summary.trim()
// Only suppress known boilerplate; recovery hints and custom summaries remain visible.
const summaryIdentity = summary.replace(
@@ -258,6 +265,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
return (
<li>
<details
open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}
className={`tool-execution tool-execution--${tool.state}`}
>
<summary>
@@ -290,7 +298,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
: tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}</p>
)}
{tool.input && (
<details className="tool-execution__input">
<details className="tool-execution__input" open={inputExpanded} onToggle={event => setInputExpanded(event.currentTarget.open)}>
<summary>{t('chat.tools.input')}</summary>
<ToolDetail label={t('chat.tools.input')} content={tool.input} formatJson onCopy={onCopy} />
</details>
@@ -322,6 +330,7 @@ function ToolExecutionList({
{tools.map((tool) => (
<ToolExecutionRow
key={tool.callId ?? tool.name}
expansionId={tool.callId ?? tool.name}
name={tool.name}
summary={tool.summary}
state={tool.state}
@@ -348,7 +357,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useMessageExpansion('subagent')
const StateIcon =
subagent.state === 'completed'
? CheckCircle2
@@ -389,6 +398,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
return (
<details
className={`subagent-status-card subagent-status-card--${subagent.state}`}
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>
@@ -450,6 +460,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
: item.block.type === 'reasoning' ? (
<MessageReasoning
key={item.block.id}
expansionId={`reasoning:${item.block.id}`}
content={item.block.content}
streaming={false}
/>
@@ -513,13 +524,12 @@ function SubagentStatusList({
className="subagent-status-list"
>
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
<MessageExpansionScope key={subagent.childTaskId} id={`subagent:${subagent.childTaskId}`}><SubagentStatusCard
renderHtml={renderHtml}
subagent={subagent}
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
onCopy={onCopy}
/>
/></MessageExpansionScope>
))}
</section>
)
@@ -581,6 +591,7 @@ function ChatMessageRowView({
}: ChatMessageRowProps): React.JSX.Element {
const { t } = useTranslation('app')
const [copied, setCopied] = useState(false)
const [citationsExpanded, setCitationsExpanded] = useMessageExpansion('citations')
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(copyResetTimer.current), [])
@@ -867,6 +878,7 @@ function ChatMessageRowView({
/>
) : item.block.type === 'reasoning' ? (
<MessageReasoning
expansionId={`reasoning:${item.block.id}`}
content={item.block.content}
key={item.block.id}
streaming={message.state === 'streaming'}
@@ -1054,7 +1066,7 @@ function ChatMessageRowView({
)}
{message.sourceReferences &&
message.sourceReferences.length > 0 && (
<details className="message-citations">
<details className="message-citations" open={citationsExpanded} onToggle={event => setCitationsExpanded(event.currentTarget.open)}>
<summary>
{t('chat.citations.view', {
count: message.sourceReferences.length
@@ -1272,7 +1284,7 @@ function ChatMessageRowView({
export const ChatMessageRow = memo(function ChatMessageRow(
props: ChatMessageRowProps
): React.JSX.Element {
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
return <MessageExpansionScope id={props.message.id}><ChatMessageRowView {...props} message={useLiveMessage(props.message)} /></MessageExpansionScope>
})
type ChatMessageWindowRowProps = ChatMessageRowProps & {
@@ -15,6 +15,66 @@ const selectHistory = (id: string) => {
}
describe('SupervisorWorkspace', () => {
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
let finishReview!: () => void
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
let finishOld!: (value: unknown) => void
let finishNew!: (value: unknown) => void
const source = vi.fn()
.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve }))
.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
const graph = vi.fn(async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
eventSources: [{ event_id: 'event', source_id: 'source' }] }))
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, run } } as never
render(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('button', { name: /Source/ })
if (trigger === 'review') {
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
}
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
await waitFor(() => expect(source).toHaveBeenCalledTimes(1))
await act(async () => { if (trigger === 'locale') await changeUiLocale('en-US'); else finishReview() })
const refreshLabel = trigger === 'locale' ? 'Refresh' : '刷新'
await waitFor(() => {
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: /Source/ })).toBeEnabled()
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
await act(async () => finishOld({ title: 'Source', content: 'Obsolete source', occurredAt: result.createdAt }))
expect(screen.queryByText('Obsolete source')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Source/ })).toBeDisabled()
await act(async () => finishNew({ title: 'Source', content: 'Current source', occurredAt: result.createdAt }))
expect(screen.getByText('Current source')).toBeVisible()
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
})
it.each(['entity', 'relation'] as const)('releases a pending %s action when locale refresh invalidates it', async kind => {
let finish!: () => void
const action = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
const graph = vi.fn(async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: 'automatic' }],
relations: [{ id: 'relation', from_entity_id: 'entity', to_entity_id: 'entity', relation_type: 'related', reason: '', confirmation_state: 'automatic' }],
sources: [], eventEntities: [], eventSources: [] }))
window.goodbuddy = { supervision: { overview: async () => [result], graph, entityAction: action, relationAction: action } } as never
render(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
fireEvent.change(screen.getByRole('combobox', { name: '图谱选择' }), { target: { value: kind } })
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Entity/ }))
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
await waitFor(() => expect(action).toHaveBeenCalledOnce())
await act(async () => { await changeUiLocale('en-US') })
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled())
await act(async () => finish())
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()
})
it.each(['zh-CN', 'en-US'] as const)('submits custom days and keeps reanalysis confirmation consistent in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat.supervisor
+1
View File
@@ -158,6 +158,7 @@ export function SupervisorWorkspace({
const refresh = useCallback(async (requestedId = selectedResult.current) => {
if (!api) return
const generation = ++loadGeneration.current
setPending(undefined)
selectedResult.current = requestedId
setLoading(true)
setGraph(emptyGraph)
+110
View File
@@ -0,0 +1,110 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Message } from './ChatTimeline'
import { MessageExpansionProvider, MessageExpansionScope, useMessageExpansion } from './message-expansion'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
function Disclosure({ id, automaticOpen = false }: { id: string; automaticOpen?: boolean }) {
const [open, setOpen] = useMessageExpansion(id, automaticOpen)
return <button aria-label={id} aria-pressed={open} onClick={() => setOpen(!open)} />
}
function captureMaps() {
const maps = new Set<Map<unknown, unknown>>()
const original = Map.prototype.set
vi.spyOn(Map.prototype, 'set').mockImplementation(function (this: Map<unknown, unknown>, key, value) {
if (value && typeof value.open === 'boolean' && typeof value.automaticOpen === 'boolean') maps.add(this)
return original.call(this, key, value)
})
return () => [...maps].reduce((total, map) => total + map.size, 0)
}
describe('message expansion retention', () => {
it('keeps streaming defaults sparse and releases an unmounted manual collapse on completion', async () => {
const entries = captureMaps()
const message: Message = { id: 'm', content: '', reasoning: 'Reason', state: 'streaming', role: 'assistant', createdAt: 1 }
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" automaticOpen={current.state === 'streaming'} /></MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(message, true))
expect(entries()).toBe(0)
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(view.getByRole('button'))
expect(entries()).toBe(1)
view.rerender(window(message, false))
view.rerender(window(message, true))
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
view.rerender(window(message, false))
const completed = { ...message, state: 'complete' as const }
await act(async () => view.rerender(window(completed, false)))
expect(entries()).toBe(0)
view.rerender(window(completed, true))
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
})
it('removes obsolete child and block identities without treating row eviction as deletion', async () => {
const entries = captureMaps()
const message: Message = { id: 'm', content: '', state: 'complete', role: 'assistant', createdAt: 1,
blocks: [{ id: 'reason', type: 'reasoning', content: 'Reason' }],
subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert', routingMode: 'native', state: 'completed' }] }
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning:reason" />
<MessageExpansionScope id="subagent:child"><Disclosure id="subagent" /></MessageExpansionScope>
</MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(message, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning:reason' }))
fireEvent.click(view.getByRole('button', { name: 'subagent' }))
view.rerender(window(message, false))
expect(entries()).toBe(2)
await act(async () => view.rerender(window({ ...message, blocks: [], subagents: [] }, false)))
expect(entries()).toBe(0)
})
it('visits 10,000 untouched rows without retaining defaults, including absent citations', () => {
const entries = captureMaps()
const messages: Message[] = Array.from({ length: 10_000 }, (_, index) => ({
id: `m${index}`, content: 'Plain message', role: 'assistant', state: 'complete', createdAt: index,
}))
const messageIndexes = new Map(messages.map((message, index) => [message.id, index]))
const window = (start: number) => <MessageExpansionProvider messages={messages} messageIndexes={messageIndexes}>
{messages.slice(start, start + 80).map(message => <MessageExpansionScope key={message.id} id={message.id}>
<Disclosure id="citations" />
</MessageExpansionScope>)}
</MessageExpansionProvider>
const view = render(window(0))
for (let start = 80; start < messages.length; start += 80) view.rerender(window(start))
expect(view.container.querySelectorAll('button').length).toBeLessThanOrEqual(80)
expect(entries()).toBe(0)
})
it('retains only overrides across eviction and cleans removed messages and disclosures', async () => {
const entries = captureMaps()
const messages: Message[] = [{ id: 'm', content: '', role: 'assistant', state: 'complete', createdAt: 1,
reasoning: 'Reason', tools: [{ callId: 'read', name: 'read', summary: '', state: 'completed', input: 'input' }] }]
const window = (items: Message[], visible: boolean) => <MessageExpansionProvider messages={items}
messageIndexes={new Map(items.map((message, index) => [message.id, index]))}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" /><Disclosure id="tool:read" /><Disclosure id="tool-input:read" /></MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(messages, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
fireEvent.click(view.getByRole('button', { name: 'tool:read' }))
fireEvent.click(view.getByRole('button', { name: 'tool-input:read' }))
expect(entries()).toBe(3)
view.rerender(window(messages, false))
expect(entries()).toBe(3)
view.rerender(window(messages, true))
expect(view.getByRole('button', { name: 'reasoning' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
expect(entries()).toBe(2)
view.rerender(window(messages, false))
await act(async () => view.rerender(window([{ ...messages[0]!, tools: undefined }], false)))
expect(entries()).toBe(0)
view.rerender(window(messages, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
expect(entries()).toBe(1)
await act(async () => view.rerender(window([], false)))
expect(entries()).toBe(0)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import type { Message } from './ChatTimeline'
type Expansion = { open: boolean; automaticOpen: boolean }
type Override = Expansion & { messageId: string; scopeId?: string; id: string }
const ExpansionContext = createContext<{
states: Map<string, Override>; path: string; messageId?: string; scopeId?: string
} | undefined>(undefined)
/** Only called for saved overrides, after message updates, never on scroll. */
function currentDefault(message: Message, entry: Override): boolean | undefined {
const child = entry.scopeId ? message.subagents?.find(item => `subagent:${item.childTaskId}` === entry.scopeId) : undefined
if (entry.scopeId && !child) return undefined
const blocks = child ? child.progress : message.blocks
const automaticOpen = !child && message.state === 'streaming'
if (entry.id === 'subagent') return child ? false : undefined
if (entry.id === 'citations') return message.sourceReferences?.length ? false : undefined
if (entry.id === 'reasoning') return !blocks?.length && message.reasoning ? automaticOpen : undefined
if (entry.id.startsWith('reasoning:')) {
return blocks?.some(block => block.type === 'reasoning' && `reasoning:${block.id}` === entry.id) ? automaticOpen : undefined
}
const input = entry.id.startsWith('tool-input:')
const toolId = entry.id.slice(input ? 'tool-input:'.length : 'tool:'.length)
const matches = (tool: NonNullable<Message['tools']>[number]) =>
(tool.callId ?? tool.name) === toolId && (!input || Boolean(tool.input))
const exists = blocks?.length ? blocks.some(block => block.type === 'tool' && matches(block.tool))
: !child && message.tools?.some(matches)
return exists ? false : undefined
}
/** Owned by the history pane, not by rows that scrolling can unmount. */
export function MessageExpansionProvider({ children, messages, messageIndexes }: {
children: ReactNode; messages: readonly Message[]; messageIndexes: ReadonlyMap<string, number>
}) {
const [value] = useState(() => ({ states: new Map<string, Override>(), path: '' }))
useEffect(() => {
for (const [key, entry] of value.states) {
const index = messageIndexes.get(entry.messageId)
const message = index === undefined ? undefined : messages[index]
if (!message || currentDefault(message, entry) !== entry.automaticOpen) value.states.delete(key)
}
}, [messages, messageIndexes, value])
return <ExpansionContext value={value}>{children}</ExpansionContext>
}
export function MessageExpansionScope({ id, children }: { id: string; children: ReactNode }) {
const parent = useContext(ExpansionContext)
const value = useMemo(() => parent && ({ states: parent.states, path: `${parent.path}/${JSON.stringify(id)}`,
messageId: parent.messageId ?? id, scopeId: parent.messageId ? id : undefined }), [parent, id])
return <ExpansionContext value={value}>{children}</ExpansionContext>
}
export function useMessageExpansion(id: string, automaticOpen = false): [boolean, (open: boolean) => void] {
const context = useContext(ExpansionContext)
const key = `${context?.path}/${JSON.stringify(id)}`
const [state, setState] = useState(() => {
const saved = context?.states.get(key)
return saved?.automaticOpen === automaticOpen ? saved : { open: automaticOpen, automaticOpen }
})
// Reasoning still opens while streaming and closes on completion, including
// when that transition happened while its row was outside the window.
if (state.automaticOpen !== automaticOpen) setState({ open: automaticOpen, automaticOpen })
return [state.open, open => {
const next = { open, automaticOpen }
if (open === automaticOpen) context?.states.delete(key)
else if (context?.messageId) context.states.set(key, { ...next, messageId: context.messageId, scopeId: context.scopeId, id })
setState(next)
}]
}