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:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user