From f42644514e049601dd64d80de5cd1dd721cac8f7 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 12:11:31 +0800 Subject: [PATCH 01/21] =?UTF-8?q?feat(transport):=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=A6=81=E7=94=A8=20SDK=20HTTP=20keep-alive=20=E4=BB=A5?= =?UTF-8?q?=E7=BC=93=E8=A7=A3=E6=B5=81=E4=B8=AD=20ECONNRESET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sdk_http_keep_alive 配置项,默认 false - 仅在请求结束后关闭 socket 复用,maxSockets 保持 50 - 补充 bunVersion / PID / streamElapsedMs / upstreamEventCount 观测字段 --- README.md | 16 ++++++++------ docs/CONFIGURATION.md | 9 ++++++++ src/__tests__/config-backfill.test.ts | 1 + src/__tests__/config-loader.test.ts | 7 ++++++ src/__tests__/request-handler.test.ts | 11 ++++++++++ src/__tests__/sdk-client.test.ts | 28 ++++++++++++++++++++++++ src/core/request/request-handler.ts | 12 ++++++++++- src/plugin/config/loader.ts | 2 ++ src/plugin/config/schema.ts | 8 +++++++ src/plugin/sdk-client.ts | 31 +++++++++++++++++++++++---- 10 files changed, 113 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b49a9a4..24bffdc 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,15 @@ multi-account or long-idle setups, enable (`token_keepalive_enabled: true`) to keep idle accounts' tokens fresh while OpenCode is running. -For long-running agent tasks that are frequently interrupted by upstream -`ECONNRESET` event-stream failures, enable -`"stream_buffer_until_complete": true`. The plugin then withholds a failed -attempt from OpenCode and safely retries it instead of exposing a partial -assistant response or partial tool call. See -[stream recovery configuration](docs/CONFIGURATION.md#options) for the latency -and quota tradeoffs. +The SDK transport uses fresh HTTP sockets by default +(`"sdk_http_keep_alive": false`) to reduce Bun stale-connection +`ECONNRESET` failures without serializing requests or delaying live tokens. +Each request pays one additional TCP/TLS handshake, while active streams and +multiple OpenCode processes remain concurrent. For workloads that prefer task +continuity over live output even after a mid-stream failure, +`"stream_buffer_until_complete": true` remains available. See +[stream recovery configuration](docs/CONFIGURATION.md#options) for the +different latency and quota tradeoffs. Paid-overage protection is on by default; see [Overage protection](docs/CONFIGURATION.md#overage-protection) before disabling diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2cf953e..29adbf1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -23,6 +23,7 @@ root [README](../README.md#configuration) for the short version. "max_request_iterations": 20, "sdk_response_timeout_enabled": false, "sdk_response_timeout_ms": 300000, + "sdk_http_keep_alive": false, "stream_event_timeout_enabled": false, "request_timeout_ms": 120000, "stream_buffer_until_complete": false, @@ -107,6 +108,14 @@ because moving a live database during an upgrade is unsafe. - `sdk_response_timeout_ms`: Fixed SDK response deadline when `sdk_response_timeout_enabled` is `true` (30000-600000ms, default: `300000`). Override with `KIRO_SDK_RESPONSE_TIMEOUT_MS`. +- `sdk_http_keep_alive`: Reuse a completed SDK HTTP connection for a later + request (default: `false`). The default gives every request a fresh socket to + avoid Bun reusing a stale pooled connection. This does not serialize or cap + active streams: the SDK still permits up to 50 concurrent sockets per client, + and multiple OpenCode processes remain independent. The tradeoff is one + additional TCP/TLS handshake per request. Set this to `true` only when + connection reuse has proven stable in your runtime. Override with + `KIRO_SDK_HTTP_KEEP_ALIVE`. - `stream_event_timeout_enabled`: Opt into a fixed inactivity deadline between upstream stream events (default: `false`). It is disabled because high-effort models can legitimately compute for several minutes between events, so event diff --git a/src/__tests__/config-backfill.test.ts b/src/__tests__/config-backfill.test.ts index 1fa2b97..cc75419 100644 --- a/src/__tests__/config-backfill.test.ts +++ b/src/__tests__/config-backfill.test.ts @@ -63,6 +63,7 @@ describe('config backfill: additive new-key insertion', () => { // newly-added default keys present with their default values expect(written.sdk_response_timeout_enabled).toBe(false) expect(written.sdk_response_timeout_ms).toBe(300000) + expect(written.sdk_http_keep_alive).toBe(false) expect(written.stream_event_timeout_enabled).toBe(false) expect(written.token_keepalive_enabled).toBe(false) expect(written.token_keepalive_interval_ms).toBe(600000) diff --git a/src/__tests__/config-loader.test.ts b/src/__tests__/config-loader.test.ts index f52058d..eb146ba 100644 --- a/src/__tests__/config-loader.test.ts +++ b/src/__tests__/config-loader.test.ts @@ -27,6 +27,7 @@ const KIRO_ENV_KEYS = [ 'KIRO_STREAM_MAX_ATTEMPTS', 'KIRO_SDK_RESPONSE_TIMEOUT_ENABLED', 'KIRO_SDK_RESPONSE_TIMEOUT_MS', + 'KIRO_SDK_HTTP_KEEP_ALIVE', 'KIRO_TOKEN_EXPIRY_BUFFER_MS', 'KIRO_USAGE_SYNC_MAX_RETRIES', 'KIRO_AUTH_SERVER_PORT_START', @@ -101,6 +102,7 @@ describe('loadConfig defaults', () => { expect(cfg.stream_max_attempts).toBe(3) expect(cfg.sdk_response_timeout_enabled).toBe(false) expect(cfg.sdk_response_timeout_ms).toBe(300000) + expect(cfg.sdk_http_keep_alive).toBe(false) expect(cfg.token_expiry_buffer_ms).toBe(300000) expect(cfg.usage_tracking_enabled).toBe(true) expect(cfg.auto_sync_kiro_cli).toBe(false) @@ -156,6 +158,11 @@ describe('loadConfig env overrides', () => { expect(loadConfig(projectDir).stream_event_timeout_enabled).toBe(true) }) + test('KIRO_SDK_HTTP_KEEP_ALIVE opts into cross-request socket reuse', () => { + process.env.KIRO_SDK_HTTP_KEEP_ALIVE = 'true' + expect(loadConfig(projectDir).sdk_http_keep_alive).toBe(true) + }) + test('KIRO_STREAM_BUFFER_UNTIL_COMPLETE opts into replay-safe stream delivery', () => { process.env.KIRO_STREAM_BUFFER_UNTIL_COMPLETE = 'true' expect(loadConfig(projectDir).stream_buffer_until_complete).toBe(true) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index e05f0f1..e1bb2fd 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -62,6 +62,7 @@ const baseConfig = { stream_max_attempts: 3, sdk_response_timeout_enabled: false, sdk_response_timeout_ms: 300000, + sdk_http_keep_alive: false, rate_limit_max_retries: 3, rate_limit_retry_delay_ms: 100, enable_log_effort_debug: false, @@ -355,6 +356,11 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { accountId: 'A', streamAttempt: 1, maxStreamAttempts: 3, + sdkHttpKeepAlive: false, + processId: process.pid, + bunVersion: process.versions.bun, + upstreamEventCount: 0, + streamElapsedMs: expect.any(Number), nextAttempt: 2, delayMs: 250, nextAccount: 'A@example.com' @@ -369,6 +375,11 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { accountId: 'A', streamAttempt: 2, maxStreamAttempts: 3, + sdkHttpKeepAlive: false, + processId: process.pid, + bunVersion: process.versions.bun, + upstreamEventCount: 0, + streamElapsedMs: expect.any(Number), attempts: 2 }) ) diff --git a/src/__tests__/sdk-client.test.ts b/src/__tests__/sdk-client.test.ts index 4a7c825..8129cea 100644 --- a/src/__tests__/sdk-client.test.ts +++ b/src/__tests__/sdk-client.test.ts @@ -27,6 +27,34 @@ describe('SDK client', () => { clearSdkClientCache() }) + test('uses fresh sockets without reducing active stream capacity', async () => { + clearSdkClientCache() + + const client = createSdkClient(auth(), 'us-east-1') + const handlerConfig = await (client.config.requestHandler as any).configProvider + + expect(handlerConfig.httpsAgent.keepAlive).toBe(false) + expect(handlerConfig.httpsAgent.maxSockets).toBe(50) + + clearSdkClientCache() + }) + + test('keeps transport modes in separate client cache entries', async () => { + clearSdkClientCache() + + const fresh = createSdkClient(auth(), 'us-east-1', undefined, { keepAlive: false }) + const reused = createSdkClient(auth(), 'us-east-1', undefined, { keepAlive: true }) + const reusedAgain = createSdkClient(auth(), 'us-east-1', undefined, { keepAlive: true }) + const reusedConfig = await (reused.config.requestHandler as any).configProvider + + expect(fresh).not.toBe(reused) + expect(reusedAgain).toBe(reused) + expect(reusedConfig.httpsAgent.keepAlive).toBe(true) + expect(reusedConfig.httpsAgent.maxSockets).toBe(50) + + clearSdkClientCache() + }) + test('injects effort before content-length is computed', async () => { clearSdkClientCache() diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 540688d..ef1f9d5 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -232,6 +232,8 @@ export class RequestHandler { handlerContext.disableReasoningReplay === true ) const streamAttempt = streamFailureCount + 1 + const streamAttemptStartedAt = Date.now() + let upstreamEventCount = 0 const streamLogDetails = ( details: Record = {} ): Record => ({ @@ -244,6 +246,11 @@ export class RequestHandler { streamAttempt, maxStreamAttempts: this.config.stream_max_attempts, streamDeliveryMode: this.config.stream_buffer_until_complete ? 'buffered' : 'live', + sdkHttpKeepAlive: this.config.sdk_http_keep_alive, + processId: process.pid, + bunVersion: process.versions.bun, + upstreamEventCount, + streamElapsedMs: Date.now() - streamAttemptStartedAt, ...details }) @@ -336,6 +343,7 @@ export class RequestHandler { { signal, onUpstreamWaitStart: ({ eventIndex }) => { + upstreamEventCount = eventIndex if (eventIndex === 0) { if (!this.config.sdk_response_timeout_enabled) endUpstreamWait() return @@ -528,7 +536,9 @@ export class RequestHandler { * identical to calling createSdkClient directly. */ private makeSdkClient(auth: KiroAuthDetails, region: string, effort?: any): any { - return createSdkClient(auth, region, effort) + return createSdkClient(auth, region, effort, { + keepAlive: this.config.sdk_http_keep_alive + }) } private prepareSdkRequest( diff --git a/src/plugin/config/loader.ts b/src/plugin/config/loader.ts index 571318e..5a25bc5 100644 --- a/src/plugin/config/loader.ts +++ b/src/plugin/config/loader.ts @@ -199,6 +199,8 @@ function applyEnvOverrides(config: KiroConfig): KiroConfig { config.sdk_response_timeout_ms ), + sdk_http_keep_alive: parseBooleanEnv(env.KIRO_SDK_HTTP_KEEP_ALIVE, config.sdk_http_keep_alive), + stream_event_timeout_enabled: parseBooleanEnv( env.KIRO_STREAM_EVENT_TIMEOUT_ENABLED, config.stream_event_timeout_enabled diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index 5de01ab..658d1c6 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -120,6 +120,13 @@ export const KiroConfigSchema = z.object({ */ sdk_response_timeout_ms: z.number().min(30000).max(600000).default(300000), + /** + * Reuse completed SDK HTTP connections across requests. Disabled by default + * because Bun can surface stale pooled sockets as mid-stream ECONNRESET. + * Active requests remain fully concurrent when this is false. + */ + sdk_http_keep_alive: z.boolean().default(false), + /** * Opt into a fixed inactivity deadline between upstream stream events. * Disabled by default because a silent event gap is ambiguous: Kiro may @@ -230,6 +237,7 @@ export const DEFAULT_CONFIG: KiroConfig = { max_request_iterations: 20, sdk_response_timeout_enabled: false, sdk_response_timeout_ms: 300000, + sdk_http_keep_alive: false, stream_event_timeout_enabled: false, request_timeout_ms: 120000, stream_buffer_until_complete: false, diff --git a/src/plugin/sdk-client.ts b/src/plugin/sdk-client.ts index 7e16e3e..5aaefd5 100644 --- a/src/plugin/sdk-client.ts +++ b/src/plugin/sdk-client.ts @@ -11,20 +11,37 @@ interface ClientCacheEntry { client: CodeWhispererStreamingClient token: string effort?: Effort + keepAlive: boolean } const clientCache = new Map() const KIRO_CLI_MAX_ATTEMPTS = 3 +const SDK_MAX_SOCKETS = 50 + +export interface SdkTransportOptions { + /** + * Controls reuse only after a request completes. It does not cap concurrent + * active streams; maxSockets remains at the Smithy default of 50. + */ + keepAlive?: boolean +} export function createSdkClient( auth: KiroAuthDetails, region: string, - effort?: Effort + effort?: Effort, + transport: SdkTransportOptions = {} ): CodeWhispererStreamingClient { - const cacheKey = `${region}:${auth.email || 'default'}:${effort || 'none'}` + const keepAlive = transport.keepAlive ?? false + const cacheKey = `${region}:${auth.email || 'default'}:${effort || 'none'}:${keepAlive ? 'keep' : 'fresh'}` const cached = clientCache.get(cacheKey) - if (cached && cached.token === auth.access && cached.effort === effort) { + if ( + cached && + cached.token === auth.access && + cached.effort === effort && + cached.keepAlive === keepAlive + ) { return cached.client } @@ -35,6 +52,12 @@ export function createSdkClient( token: () => Promise.resolve({ token }), maxAttempts: KIRO_CLI_MAX_ATTEMPTS, retryMode: 'standard', + requestHandler: { + httpsAgent: { + keepAlive, + maxSockets: SDK_MAX_SOCKETS + } + }, customUserAgent: [[KIRO_CONSTANTS.USER_AGENT]] }) @@ -68,7 +91,7 @@ export function createSdkClient( ) } - clientCache.set(cacheKey, { client, token, effort }) + clientCache.set(cacheKey, { client, token, effort, keepAlive }) return client } From a813442854e847000085e7a9bc8f83119be6000f Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 12:24:44 +0800 Subject: [PATCH 02/21] =?UTF-8?q?feat(streaming):=20=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=E6=8D=A2=E5=99=A8=E5=8F=AA=E8=AF=BB=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E7=8A=B6=E6=80=81=EF=BC=88sawToolIntent/reasoning=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5/dialect=20=E7=8A=B6=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/stream-observability.test.ts | 262 ++++++++++++++++++ src/core/request/response-handler.ts | 14 +- .../streaming/sdk-stream-transformer.ts | 20 +- src/plugin/streaming/stream-observer.ts | 78 ++++++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/stream-observability.test.ts create mode 100644 src/plugin/streaming/stream-observer.ts diff --git a/src/__tests__/stream-observability.test.ts b/src/__tests__/stream-observability.test.ts new file mode 100644 index 0000000..c6be68b --- /dev/null +++ b/src/__tests__/stream-observability.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from 'bun:test' +import { DSML_MARKER } from '../infrastructure/transformers/tool-call-parser.js' +import { transformSdkStream } from '../plugin/streaming/sdk-stream-transformer.js' +import { StreamObserver } from '../plugin/streaming/stream-observer.js' + +// Same fake-SDK shape the other transformer suites use: transformSdkStream reads +// `sdkResponse.generateAssistantResponseResponse` as an async iterable of events. +function makeSdkResponse(events: any[]): any { + return { + generateAssistantResponseResponse: (async function* () { + for (const e of events) yield e + })() + } +} + +// An event stream that dies mid-flight, standing in for a post-200 iterator failure. +function makeBreakingSdkResponse(events: any[]): any { + return { + generateAssistantResponseResponse: (async function* () { + for (const e of events) yield e + throw new Error('upstream event stream broke') + })() + } +} + +async function collectUntilBreak( + events: any[], + observer: StreamObserver +): Promise<{ chunks: any[]; broke: boolean }> { + const chunks: any[] = [] + let broke = false + try { + for await (const chunk of transformSdkStream( + makeBreakingSdkResponse(events), + 'auto', + 'chatcmpl-obs', + undefined, + observer + )) { + chunks.push(chunk) + } + } catch { + broke = true + } + return { chunks, broke } +} + +async function collectAll(events: any[], observer?: StreamObserver): Promise { + const chunks: any[] = [] + for await (const chunk of transformSdkStream( + makeSdkResponse(events), + 'auto', + 'chatcmpl-obs', + undefined, + observer + )) { + chunks.push(chunk) + } + return chunks +} + +function toolCallChunks(chunks: any[]): any[] { + return chunks.filter((c) => c?.choices?.[0]?.delta?.tool_calls !== undefined) +} + +function contentOf(chunk: any): string | undefined { + return chunk?.choices?.[0]?.delta?.content +} + +// Two run-scoped values are legitimately unstable across two runs of the same +// sequence and are NOT part of the emit contract: the per-chunk wall-clock +// `created` second, and the synthetic `tool__` id the text-dialect +// parser mints for a call the upstream never gave an id. Everything else — key +// order included — must match byte for byte. +const SYNTHETIC_TOOL_ID = /^tool_\d+_[a-z0-9]+$/ + +function normalize(chunks: any[]): string { + const stable = JSON.stringify(chunks, (key, value) => { + if (key === 'created') return 0 + if (key === 'id' && typeof value === 'string' && SYNTHETIC_TOOL_ID.test(value)) { + return 'tool_synthetic' + } + return value + }) + return stable +} + +describe('StreamObserver — ingestion-time tool intent', () => { + test('toolUseEvent then stream break: sawToolIntent true with zero tool chunks emitted', async () => { + // Given: reasoning + text land, a raw toolUseEvent arrives, then the stream dies + // before the transformer's end-of-stream tool flush. + const observer = new StreamObserver() + const { chunks, broke } = await collectUntilBreak( + [ + { assistantResponseEvent: { content: 'working on it' } }, + { toolUseEvent: { toolUseId: 'tu-1', name: 'read', input: '{"path":"/tmp/x"}' } } + ], + observer + ) + + // Then: the iterator failed, no tool_calls chunk ever reached the consumer, + // yet the intent is already observable. + expect(broke).toBe(true) + expect(toolCallChunks(chunks).length).toBe(0) + expect(observer.sawToolIntent).toBe(true) + expect(observer.snapshot().sawToolIntent).toBe(true) + }) + + test('incomplete toolUseEvent (no name/id) still counts as tool intent', async () => { + // Given: a toolUseEvent-family event that the transformer itself discards. + const observer = new StreamObserver() + const { chunks } = await collectUntilBreak( + [{ toolUseEvent: { input: '{"partial":' } }], + observer + ) + + expect(toolCallChunks(chunks).length).toBe(0) + expect(observer.sawToolIntent).toBe(true) + }) + + test('text-dialect tool marker then stream break: sawToolIntent and dialectActive true', async () => { + const observer = new StreamObserver() + const { chunks, broke } = await collectUntilBreak( + [ + { assistantResponseEvent: { content: 'let me look' } }, + { assistantResponseEvent: { content: '/tmp' } } + ], + observer + ) + + expect(broke).toBe(true) + expect(toolCallChunks(chunks).length).toBe(0) + expect(observer.dialectActive).toBe(true) + expect(observer.sawToolIntent).toBe(true) + // The dialect span itself must never have been streamed as visible text. + const visible = chunks.map((c) => contentOf(c) ?? '').join('') + expect(visible).not.toContain(' { + const observer = new StreamObserver() + await collectUntilBreak( + [{ assistantResponseEvent: { content: `answer\n${DSML_MARKER} name="grep"` } }], + observer + ) + + expect(observer.dialectActive).toBe(true) + expect(observer.sawToolIntent).toBe(true) + }) +}) + +describe('StreamObserver — reasoning phase', () => { + test('pure reasoning then break: phase active, no tool intent', async () => { + const observer = new StreamObserver() + const { chunks, broke } = await collectUntilBreak( + [ + { reasoningContentEvent: { text: 'Let me' } }, + { reasoningContentEvent: { text: ' think' } } + ], + observer + ) + + expect(broke).toBe(true) + expect(observer.reasoningPhase).toBe('active') + expect(observer.sawToolIntent).toBe(false) + expect(observer.dialectActive).toBe(false) + // Reasoning WAS delivered before the break — that is what makes this Tier A. + const reasoning = chunks.map((c) => c?.choices?.[0]?.delta?.reasoning_content ?? '').join('') + expect(reasoning).toBe('Let me think') + }) + + test('reasoning then text then break: phase ended', async () => { + const observer = new StreamObserver() + await collectUntilBreak( + [ + { reasoningContentEvent: { text: 'thinking' } }, + { assistantResponseEvent: { content: 'answer' } } + ], + observer + ) + + expect(observer.reasoningPhase).toBe('ended') + expect(observer.sawToolIntent).toBe(false) + }) + + test('text-only stream: phase stays none', async () => { + const observer = new StreamObserver() + await collectAll([{ assistantResponseEvent: { content: 'plain answer' } }], observer) + + expect(observer.reasoningPhase).toBe('none') + expect(observer.snapshot()).toEqual({ + sawToolIntent: false, + reasoningPhase: 'none', + dialectActive: false + }) + }) + + test('inline tag stream: phase reaches ended after the closing tag', async () => { + const observer = new StreamObserver() + await collectAll( + [{ assistantResponseEvent: { content: 'weighing\n\nthe reply' } }], + observer + ) + + expect(observer.reasoningPhase).toBe('ended') + }) + + test('unterminated inline : phase ends when the buffer is flushed', async () => { + const observer = new StreamObserver() + await collectAll([{ assistantResponseEvent: { content: 'never closed' } }], observer) + + expect(observer.reasoningPhase).toBe('ended') + }) +}) + +describe('StreamObserver — emitted chunks are byte-identical with and without it', () => { + const sequences: Array<{ name: string; events: any[] }> = [ + { + name: 'reasoning + text', + events: [ + { reasoningContentEvent: { text: 'Let me think' } }, + { reasoningContentEvent: { signature: 'sig-abc' } }, + { assistantResponseEvent: { content: 'The answer' } }, + { assistantResponseEvent: { content: ' is 42' } }, + { metadataEvent: { contextUsagePercentage: 12 } } + ] + }, + { + name: 'text + structured toolUse', + events: [ + { assistantResponseEvent: { content: 'reading the file' } }, + { + toolUseEvent: { toolUseId: 'tu-1', name: 'read', input: '{"path":"/tmp/x"}', stop: true } + }, + { metadataEvent: { tokenUsage: { inputTokens: 10, outputTokens: 5 } } } + ] + }, + { + name: 'text-dialect tool call', + events: [ + { assistantResponseEvent: { content: 'before ' } }, + { + assistantResponseEvent: { + content: '/tmp/x' + } + }, + { assistantResponseEvent: { content: ' after' } } + ] + } + ] + + for (const { name, events } of sequences) { + test(`${name}: identical chunk sequence`, async () => { + const withoutObserver = await collectAll(events) + const observer = new StreamObserver() + const withObserver = await collectAll(events, observer) + + expect(withObserver.length).toBe(withoutObserver.length) + expect(normalize(withObserver)).toBe(normalize(withoutObserver)) + }) + } +}) diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index d847e35..2f2d01a 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -7,6 +7,7 @@ import { parseEventStream } from '../../plugin/response' import { transformKiroStream } from '../../plugin/streaming/index.js' import { ReasoningAccumulator } from '../../plugin/streaming/reasoning-accumulator.js' import { transformSdkStream } from '../../plugin/streaming/sdk-stream-transformer.js' +import type { StreamObserver } from '../../plugin/streaming/stream-observer.js' import type { KiroReasoningContent } from '../../plugin/types.js' import { SdkEventStreamIterationError } from './stream-error.js' @@ -43,6 +44,11 @@ export interface SdkResponseLifecycle { /** Loop root recovered from inbound history, if any. */ inheritedLoopId?: string effectiveModel?: string + /** + * Owned by the caller so the ingestion-time signals stay readable after this + * attempt fails — the streaming branch only feeds it. + */ + streamObserver?: StreamObserver } interface WrappedSdkStream { @@ -279,7 +285,13 @@ export class ResponseHandler { ) const reasoning = new ReasoningAccumulator() const emitted = new EmittedOutputAccumulator() - const transformed = transformSdkStream(wrapped.response, model, conversationId, reasoning) + const transformed = transformSdkStream( + wrapped.response, + model, + conversationId, + reasoning, + lifecycle.streamObserver + ) const buffered: Uint8Array[] = [] // One shared publication point for all three completion paths. Duplicating it // per site is how the live pull-driven path silently stops populating. diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index 11c2ddd..130308f 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -3,6 +3,7 @@ import { estimateTokens } from '../response.js' import { DialectGate } from './dialect-gate.js' import { convertToOpenAI } from './openai-converter.js' import type { ReasoningAccumulator, ReasoningContentEventLike } from './reasoning-accumulator.js' +import type { StreamObserver } from './stream-observer.js' import { findRealTag } from './stream-parser.js' import { createTextDeltaEvents, createThinkingDeltaEvents, stopBlock } from './stream-state.js' import { @@ -13,11 +14,18 @@ import { ToolCallState } from './types.js' +/** + * `reasoningAccumulator` and `observer` are optional write-only collaborators: + * the transformer feeds them and never reads them back, so neither can change + * an emitted chunk. They stay separate trailing parameters rather than one + * options bag because every existing call site passes positionally. + */ export async function* transformSdkStream( sdkResponse: any, model: string, conversationId: string, - reasoningAccumulator?: ReasoningAccumulator + reasoningAccumulator?: ReasoningAccumulator, + observer?: StreamObserver ): AsyncGenerator { const thinkingRequested = true @@ -45,6 +53,7 @@ export async function* transformSdkStream( const toChunk = (ev: StreamEvent): any => { if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') { const safe = dialectGate.push(ev.delta.text ?? '') + if (dialectGate.suppressing) observer?.noteDialectToolIntent() if (!safe) return null const gated: StreamEvent = { ...ev, delta: { ...ev.delta, text: safe } } return convertToOpenAI(gated, conversationId, model) @@ -78,6 +87,7 @@ export async function* transformSdkStream( reasoningClosed = false } reasoningStarted = true + observer?.noteReasoningStarted() for (const ev of createThinkingDeltaEvents(reasoningText, streamState)) { const _c = convertToOpenAI(ev, conversationId, model) if (_c !== null) yield _c @@ -96,6 +106,7 @@ export async function* transformSdkStream( if (_c !== null) yield _c } reasoningClosed = true + observer?.noteReasoningEnded() } if (reasoningStarted) { @@ -129,6 +140,7 @@ export async function* transformSdkStream( } streamState.buffer = streamState.buffer.slice(startPos + THINKING_START_TAG.length) streamState.inThinking = true + observer?.noteReasoningStarted() continue } @@ -153,6 +165,7 @@ export async function* transformSdkStream( streamState.buffer = streamState.buffer.slice(endPos + THINKING_END_TAG.length) streamState.inThinking = false streamState.thinkingExtracted = true + observer?.noteReasoningEnded() deltaEvents.push(...createThinkingDeltaEvents('', streamState)) deltaEvents.push(...stopBlock(streamState.thinkingBlockIndex, streamState)) if (streamState.buffer.startsWith('\n\n')) { @@ -188,6 +201,9 @@ export async function* transformSdkStream( } } else if (event.toolUseEvent) { const tc = event.toolUseEvent + // Tool intent is recorded at ingestion, not at the end-of-stream flush below: + // a stream that dies here has tool intent but zero emitted tool_calls. + observer?.noteRawToolIntent() if (tc.name && tc.toolUseId) { if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) { @@ -229,6 +245,7 @@ export async function* transformSdkStream( if (_c !== null) yield _c } reasoningClosed = true + observer?.noteReasoningEnded() } if (thinkingRequested && streamState.buffer) { @@ -246,6 +263,7 @@ export async function* transformSdkStream( const _c = convertToOpenAI(ev, conversationId, model) if (_c !== null) yield _c } + observer?.noteReasoningEnded() } else { for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) { const _c = toChunk(ev) diff --git a/src/plugin/streaming/stream-observer.ts b/src/plugin/streaming/stream-observer.ts new file mode 100644 index 0000000..2dd5e68 --- /dev/null +++ b/src/plugin/streaming/stream-observer.ts @@ -0,0 +1,78 @@ +/** + * Where the reasoning/thinking channel stands at the moment of observation. + * + * - `none` — no reasoning block has ever opened on this attempt. + * - `active` — a reasoning block is open right now (nothing closed it yet). + * - `ended` — a reasoning block opened and was closed. + */ +export type ReasoningPhase = 'none' | 'active' | 'ended' + +export interface StreamObservedState { + /** + * True once the attempt has ANY evidence of tool intent, at ingestion time — + * long before the transformer flushes `tool_calls` at stream end. Two sources: + * a raw `toolUseEvent` from the SDK, or a text-dialect tool marker entering + * the dialect gate. + */ + sawToolIntent: boolean + reasoningPhase: ReasoningPhase + /** True once the dialect gate started withholding text (a marker appeared). */ + dialectActive: boolean +} + +/** + * Observes ONE stream attempt's ingestion-time signals for the recovery tier + * decision. Observation only: the transformer never reads it back, so attaching + * an observer cannot change a single emitted chunk. + * + * Read it AFTER the attempt ends — successfully or by iterator failure. The + * whole point is that `sawToolIntent` is already true when a stream dies before + * the transformer's end-of-stream tool flush, which is exactly the case where + * naive replay would double-execute a tool. + */ +export class StreamObserver { + private toolIntent = false + private phase: ReasoningPhase = 'none' + private dialect = false + + /** A raw SDK `toolUseEvent`-family event arrived (name/id completeness irrelevant). */ + noteRawToolIntent(): void { + this.toolIntent = true + } + + /** The dialect gate observed a text-dialect tool-call opening marker. */ + noteDialectToolIntent(): void { + this.dialect = true + this.toolIntent = true + } + + /** A reasoning/thinking block opened (native reasoning run or inline tag). */ + noteReasoningStarted(): void { + this.phase = 'active' + } + + /** The open reasoning/thinking block closed. Never downgrades `none`. */ + noteReasoningEnded(): void { + if (this.phase === 'active') this.phase = 'ended' + } + + get sawToolIntent(): boolean { + return this.toolIntent + } + + get reasoningPhase(): ReasoningPhase { + return this.phase + } + + get dialectActive(): boolean { + return this.dialect + } + + snapshot(): StreamObservedState { + return { + sawToolIntent: this.toolIntent, + reasoningPhase: this.phase, + dialectActive: this.dialect + } + } +} From 9a834bc5d775c902b2bd711ba285275e95b0a56d Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 12:41:00 +0800 Subject: [PATCH 03/21] =?UTF-8?q?feat(request):=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=A1=A5=E4=B8=89=E9=80=9A=E9=81=93=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E5=AD=97=E6=AE=B5=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=97=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=8F=91=E9=80=81=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamLogDetails 追加 emittedReasoningChars / emittedVisibleChars / emittedToolCount / sawToolIntent,数值来自每个 attempt 自有的 EmittedOutputAccumulator 与 StreamObserver,因此流失败后仍可读。 只记长度与计数,redaction 口径与 API 日志 sink 一致。 新增两个导出的日志事件名常量: - STREAM_REQUEST_STARTED_LOG:每个入站流式请求首次发送时无条件写一条 轻量记录,不受 enable_log_api_request 影响,作为流失败发生率指标的分母 (此前没有任何无条件的发送记录,指标不可测)。守卫用请求作用域布尔而非 仅 streamAttempt === 1 —— HTTP 错误换号不动 streamFailureCount, 单条件会重复打点并高估分母。 - STREAM_MISSING_COMPLETION_LOG:SDK 迭代器干净 done 但从未收到 completion metadata 时告警。该形态此前完全静默(合成 finish_reason:"stop" 并落地全部 成功副作用),零日志点,发生率不可测。标记放在 handleSdkStreaming 共享的 complete() 入口,一处覆盖 buffered / 输出前 done / live pull 三条路径。 纯观测,零行为改动:不 throw、不改控制流、不改任何下发的 SSE 分块。 Refs: .omo/plans/stream-recovery-live-mode.md T0.4 --- src/__tests__/reasoning-log-redaction.test.ts | 78 ++++ src/__tests__/request-handler.test.ts | 337 +++++++++++++++++- src/core/request/request-handler.ts | 40 +++ src/core/request/response-handler.ts | 30 +- 4 files changed, 478 insertions(+), 7 deletions(-) diff --git a/src/__tests__/reasoning-log-redaction.test.ts b/src/__tests__/reasoning-log-redaction.test.ts index dda7ff5..118c8d3 100644 --- a/src/__tests__/reasoning-log-redaction.test.ts +++ b/src/__tests__/reasoning-log-redaction.test.ts @@ -33,6 +33,7 @@ import type { const MODEL = 'claude-opus-5' const SIG = `sig-${'A'.repeat(320)}` const REASONING = 'private chain of thought that the signature covers' +const STREAMED_REPLY = 'the streamed reply body that must never reach a log' const REDACTED_BYTES = new Uint8Array( Array.from({ length: 96 }, (_value, index) => (index * 7 + 13) % 256) ) @@ -378,6 +379,83 @@ function wireHandler(prep: SdkPreparedRequest, send: () => Promise): Re return handler } +function streamingPrep(): SdkPreparedRequest { + return { ...signedPrep(), streaming: true } +} + +function sdkStreamOf(events: unknown[], failure?: Error): () => Promise { + return async () => ({ + generateAssistantResponseResponse: (async function* () { + for (const event of events) yield event + if (failure) throw failure + })() + }) +} + +async function drain(response: Response): Promise { + const reader = response.body!.getReader() + try { + while (!(await reader.read()).done) {} + } catch {} +} + +describe('§6.8 redaction — stream observability fields', () => { + test('failure-log channel fields carry volume only, never reasoning or reply text', async () => { + const handler = wireHandler( + streamingPrep(), + sdkStreamOf( + [ + { reasoningContentEvent: { text: REASONING } }, + { assistantResponseEvent: { content: STREAMED_REPLY } } + ], + new Error('stream died after output') + ) + ) + + await drain( + await handler.handle( + KIRO_URL, + { body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'go' }] }) }, + noToast + ) + ) + + const text = allLogText() + expect(text).toContain(`"emittedReasoningChars":${REASONING.length}`) + expect(text).toContain(`"emittedVisibleChars":${STREAMED_REPLY.length}`) + expect(text).toContain('"emittedToolCount":0') + expect(text).toContain('"sawToolIntent":false') + expect(text).not.toContain(REASONING) + expect(text).not.toContain(STREAMED_REPLY) + expectNoLeak(text) + }) + + test('the missing-completion-metadata marker carries volume only', async () => { + const handler = wireHandler( + streamingPrep(), + sdkStreamOf([ + { reasoningContentEvent: { text: REASONING } }, + { assistantResponseEvent: { content: STREAMED_REPLY } } + ]) + ) + + await drain( + await handler.handle( + KIRO_URL, + { body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'go' }] }) }, + noToast + ) + ) + + const text = allLogText() + expect(text).toContain('Kiro stream ended without completion metadata') + expect(text).toContain(`"emittedVisibleChars":${STREAMED_REPLY.length}`) + expect(text).not.toContain(REASONING) + expect(text).not.toContain(STREAMED_REPLY) + expectNoLeak(text) + }) +}) + describe('§6.8 redaction — end to end with enable_log_api_request', () => { test('an SDK error thrown while a signed history is in flight leaks nothing', async () => { const prep = signedPrep() diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index e1bb2fd..e1e8eb7 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' import { TokenRefresher } from '../core/auth/token-refresher.js' -import { RequestHandler } from '../core/request/request-handler.js' +import { + RequestHandler, + STREAM_MISSING_COMPLETION_LOG, + STREAM_REQUEST_STARTED_LOG +} from '../core/request/request-handler.js' import { ResponseHandler } from '../core/request/response-handler.js' import { encodeRefreshToken } from '../kiro/auth.js' import { AccountManager } from '../plugin/accounts.js' @@ -892,6 +896,126 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { expect(fakes.errorHandler.handleNetworkError).toHaveBeenCalledTimes(0) }) + test('an exhausted attempt reports how much of each channel was already emitted', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'abc' } }, + { assistantResponseEvent: { content: 'hello' } } + ], + new Error('buffered stream died') + ) + ], + streaming: true, + useRealResponseHandler: true, + streamBufferUntilComplete: true, + streamMaxAttempts: 1 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + expect(response.status).toBe(503) + expect(logs.error).toHaveBeenCalledWith( + 'Kiro SDK event stream iteration failed', + expect.objectContaining({ + outcome: 'exhausted', + emittedReasoningChars: 3, + emittedVisibleChars: 5, + emittedToolCount: 0, + sawToolIntent: false + }) + ) + } finally { + logs.restore() + } + }) + + test('tool intent before the break is reported even though no tool call was emitted', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'abc' } }, + { toolUseEvent: { name: 'read_file', toolUseId: 'tool-1', input: '{"path":"/a' } } + ], + new Error('died mid tool call') + ) + ], + streaming: true, + useRealResponseHandler: true, + streamBufferUntilComplete: true, + streamMaxAttempts: 1 + }) + installImmediateStreamBackoff(handler) + + await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + expect(logs.error).toHaveBeenCalledWith( + 'Kiro SDK event stream iteration failed', + expect.objectContaining({ + outcome: 'exhausted', + emittedReasoningChars: 3, + emittedVisibleChars: 0, + emittedToolCount: 0, + sawToolIntent: true + }) + ) + } finally { + logs.restore() + } + }) + + test('a post-output failure reports the volumes delivered before the break', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'abc' } }, + { assistantResponseEvent: { content: 'hello' } } + ], + new Error('late stream failure') + ) + ], + streaming: true, + useRealResponseHandler: true + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const reader = response.body!.getReader() + const drained = (async () => { + while (!(await reader.read()).done) {} + })() + + await expect(drained).rejects.toMatchObject({ name: 'UpstreamUnexpectedError' }) + expect(logs.error).toHaveBeenCalledWith( + 'Kiro SDK event stream iteration failed', + expect.objectContaining({ + outcome: 'terminated_after_output', + emittedReasoningChars: 3, + emittedVisibleChars: 5, + emittedToolCount: 0, + sawToolIntent: false + }) + ) + } finally { + logs.restore() + } + }) + test('stream retry jitter stays inside the documented bounds', () => { const { handler } = buildHandler({}) const internals = handler as unknown as { @@ -908,6 +1032,217 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { }) }) +describe('RequestHandler.handle — unconditional stream-start record', () => { + function startRecords(log: ReturnType['log']): unknown[][] { + return log.mock.calls.filter((call) => call[0] === STREAM_REQUEST_STARTED_LOG) + } + + test('one record is written per inbound request, carrying only correlation fields', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([{ assistantResponseEvent: { content: 'answer' } }])], + streaming: true, + useRealResponseHandler: true + }) + + const response = await handler.handle( + KIRO_URL, + { body: JSON.stringify({ model: 'x' }) }, + noToast + ) + await response.text() + + const records = startRecords(logs.log) + expect(records).toHaveLength(1) + expect(records[0]![1]).toEqual({ + conversationId: 'c1', + model: 'x', + effectiveModel: 'claude-sonnet-4-5', + processId: process.pid + }) + } finally { + logs.restore() + } + }) + + test('stream retries within one request add no further records', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([], new Error('decode-1')), + sdkStream([], new Error('decode-2')), + sdkStream([{ assistantResponseEvent: { content: 'third time lucky' } }]) + ], + streaming: true, + useRealResponseHandler: true + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(3) + expect(startRecords(logs.log)).toHaveLength(1) + } finally { + logs.restore() + } + }) + + test('an account switch driven by an HTTP error adds no further records', async () => { + const acc1 = makeAccount({ id: 'A' }) + const acc2 = makeAccount({ id: 'B' }) + const httpError: any = new Error('rate limited') + httpError.$metadata = { httpStatusCode: 429 } + httpError.name = 'ThrottlingException' + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc1, acc2], + sdkResults: [httpError, sdkStream([{ assistantResponseEvent: { content: 'from B' } }])], + errorHandleResults: [{ shouldRetry: true, switchAccount: true }], + streaming: true, + useRealResponseHandler: true + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(startRecords(logs.log)).toHaveLength(1) + } finally { + logs.restore() + } + }) + + test('a non-streaming request writes no record', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([{ assistantResponseEvent: { content: 'answer' } }])], + useRealResponseHandler: true + }) + + await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + expect(startRecords(logs.log)).toHaveLength(0) + } finally { + logs.restore() + } + }) +}) + +describe('RequestHandler.handle — clean end without completion metadata', () => { + test('the marker is logged while the response still finishes exactly as before', async () => { + const acc = makeAccount({ id: 'A', failCount: 2, unhealthyReason: 'transient' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([{ assistantResponseEvent: { content: 'partial answer' } }])], + streaming: true, + useRealResponseHandler: true + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + const streamedContent = body + .split('\n\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice('data: '.length)).choices?.[0]?.delta?.content ?? '') + .join('') + + expect(streamedContent).toBe('partial answer') + expect(body).toContain('"finish_reason":"stop"') + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect(acc.failCount).toBe(0) + expect(logs.warn).toHaveBeenCalledWith( + STREAM_MISSING_COMPLETION_LOG, + expect.objectContaining({ + outcome: 'clean_eof_without_completion_metadata', + conversationId: 'c1', + accountId: 'A', + streamAttempt: 1, + emittedReasoningChars: 0, + emittedVisibleChars: 'partial answer'.length, + emittedToolCount: 0, + sawToolIntent: false + }) + ) + } finally { + logs.restore() + } + }) + + test('a stream carrying completion metadata logs no marker', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([ + { assistantResponseEvent: { content: 'complete answer' } }, + { + metadataEvent: { + tokenUsage: { uncachedInputTokens: 3, outputTokens: 2, totalTokens: 5 } + } + } + ]) + ], + streaming: true, + useRealResponseHandler: true + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(logs.warn.mock.calls.some((call) => call[0] === STREAM_MISSING_COMPLETION_LOG)).toBe( + false + ) + } finally { + logs.restore() + } + }) + + test('the marker also covers a buffered stream that ends without metadata', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([{ reasoningContentEvent: { text: 'abc' } }])], + streaming: true, + useRealResponseHandler: true, + streamBufferUntilComplete: true + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(body).toContain('"finish_reason":"stop"') + expect(logs.warn).toHaveBeenCalledWith( + STREAM_MISSING_COMPLETION_LOG, + expect.objectContaining({ + outcome: 'clean_eof_without_completion_metadata', + emittedReasoningChars: 3, + emittedVisibleChars: 0, + streamDeliveryMode: 'buffered' + }) + ) + } finally { + logs.restore() + } + }) +}) + describe('RequestHandler.handle — cancellation and queue release', () => { test('inbound abort interrupts a pending send without retry and releases the next request', async () => { const acc = makeAccount({ id: 'A' }) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index ef1f9d5..b4ace56 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -9,9 +9,11 @@ import type { KiroConfig } from '../../plugin/config' import { isPermanentError } from '../../plugin/health' import * as logger from '../../plugin/logger' import { reasoningCorrelationCache } from '../../plugin/reasoning/correlation-cache' +import { EmittedOutputAccumulator } from '../../plugin/reasoning/emitted-output' import { deriveInheritedLoopId, normalizeToolArguments } from '../../plugin/reasoning/turn-identity' import { transformToSdkRequest } from '../../plugin/request' import { createSdkClient } from '../../plugin/sdk-client' +import { StreamObserver } from '../../plugin/streaming/stream-observer' import { syncFromKiroCli } from '../../plugin/sync/kiro-cli' import type { KiroAuthDetails, ManagedAccount, SdkPreparedRequest } from '../../plugin/types' import { AccountSelector } from '../account/account-selector' @@ -29,6 +31,17 @@ const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/ const REAUTH_FAILURE_COOLDOWN_MS = 60000 type UpstreamWaitPhase = 'SDK response' | 'stream event' +/** + * Written once per inbound streaming request, unconditionally — it is the + * denominator every stream-failure rate is measured against, so it must not + * depend on `enable_log_api_request`. Log-analysis scripts match this exact + * string; changing it invalidates every window collected before the change. + */ +export const STREAM_REQUEST_STARTED_LOG = 'Kiro stream request started' + +/** Emitted on a clean SDK `done` that never carried completion metadata. */ +export const STREAM_MISSING_COMPLETION_LOG = 'Kiro stream ended without completion metadata' + function describeError(error: unknown, depth = 0): unknown { if (!(error instanceof Error)) return String(error) const code = (error as Error & { code?: unknown }).code @@ -164,6 +177,7 @@ export class RequestHandler { let handlerContext: RequestContext = { retry: 0, forcedRefreshAccountIds: new Set() } let consecutiveNullAccounts = 0 + let streamStartRecorded = false let streamFailureCount = 0 let forcedStreamAccount: ManagedAccount | null = null let pinnedAccount: ManagedAccount | null = null @@ -233,6 +247,8 @@ export class RequestHandler { ) const streamAttempt = streamFailureCount + 1 const streamAttemptStartedAt = Date.now() + const streamObserver = new StreamObserver() + const emittedOutput = new EmittedOutputAccumulator() let upstreamEventCount = 0 const streamLogDetails = ( details: Record = {} @@ -251,9 +267,25 @@ export class RequestHandler { bunVersion: process.versions.bun, upstreamEventCount, streamElapsedMs: Date.now() - streamAttemptStartedAt, + // Volume only, never content: the same redaction rule the API log sink + // follows. A char count cannot reconstruct reasoning or reply text. + emittedReasoningChars: emittedOutput.reasoningText.length, + emittedVisibleChars: emittedOutput.visibleText.length, + emittedToolCount: emittedOutput.toolUses().length, + sawToolIntent: streamObserver.sawToolIntent, ...details }) + if (sdkPrep.streaming && streamAttempt === 1 && !streamStartRecorded) { + streamStartRecorded = true + logger.log(STREAM_REQUEST_STARTED_LOG, { + conversationId: sdkPrep.conversationId, + model, + effectiveModel: sdkPrep.effectiveModel, + processId: process.pid + }) + } + if (this.config.enable_log_effort_debug) { try { logger.log('[effort-debug] request effort resolution', { @@ -370,7 +402,15 @@ export class RequestHandler { }) ) }, + onCleanEofWithoutCompletionMetadata: () => { + logger.warn( + STREAM_MISSING_COMPLETION_LOG, + streamLogDetails({ outcome: 'clean_eof_without_completion_metadata' }) + ) + }, onComplete: onStreamComplete, + streamObserver, + emittedOutput, attemptId, ...(inheritedLoopId !== undefined ? { inheritedLoopId } : {}), effectiveModel: sdkPrep.effectiveModel, diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index 2f2d01a..399c19e 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -49,11 +49,24 @@ export interface SdkResponseLifecycle { * attempt fails — the streaming branch only feeds it. */ streamObserver?: StreamObserver + /** + * Owned by the caller for the same reason as `streamObserver`: the emitted + * per-channel volume has to stay readable after the attempt fails. Defaults to + * an internal instance when absent, so callers that do not observe are unchanged. + */ + emittedOutput?: EmittedOutputAccumulator + /** + * The SDK iterator reached a clean `done` without ever delivering completion + * metadata. Observation only — success handling proceeds exactly as before. + */ + onCleanEofWithoutCompletionMetadata?: () => void } interface WrappedSdkStream { response: any closeRaw: () => Promise + /** Whether a `metadataEvent.tokenUsage` event was seen so far on this attempt. */ + completionMetadataSeen: () => boolean } function abortReason(signal: AbortSignal): unknown { @@ -82,7 +95,7 @@ function wrapSdkEventStream( ): WrappedSdkStream { const eventStream = sdkResponse.generateAssistantResponseResponse if (!eventStream || typeof eventStream[Symbol.asyncIterator] !== 'function') { - return { response: sdkResponse, closeRaw: async () => {} } + return { response: sdkResponse, closeRaw: async () => {}, completionMetadataSeen: () => false } } const rawIterator = eventStream[Symbol.asyncIterator]() as AsyncIterator @@ -166,7 +179,8 @@ function wrapSdkEventStream( return { response: { ...sdkResponse, generateAssistantResponseResponse: wrappedStream }, - closeRaw + closeRaw, + completionMetadataSeen: () => completionMetadataSeen } } @@ -284,7 +298,7 @@ export class ResponseHandler { lifecycle.onIterationError ) const reasoning = new ReasoningAccumulator() - const emitted = new EmittedOutputAccumulator() + const emitted = lifecycle.emittedOutput ?? new EmittedOutputAccumulator() const transformed = transformSdkStream( wrapped.response, model, @@ -294,9 +308,13 @@ export class ResponseHandler { ) const buffered: Uint8Array[] = [] // One shared publication point for all three completion paths. Duplicating it - // per site is how the live pull-driven path silently stops populating. - const complete = async (): Promise => - this.fireCompletion(lifecycle, reasoning, emitted, model) + // per site is how the live pull-driven path silently stops populating. It is + // also the only place a clean `done` is observable, so the missing-completion + // marker fires here, before completion, rather than at each `item.done`. + const complete = async (): Promise => { + if (!wrapped.completionMetadataSeen()) lifecycle.onCleanEofWithoutCompletionMetadata?.() + return this.fireCompletion(lifecycle, reasoning, emitted, model) + } if (lifecycle.bufferUntilComplete) { try { From 18ef21b3afd27e36a7abbae32f8f5eaabacde573 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 12:44:46 +0800 Subject: [PATCH 04/21] =?UTF-8?q?docs(config):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=B5=81=E8=A7=82=E6=B5=8B=E5=AD=97=E6=AE=B5=E4=B8=8E=20stream?= =?UTF-8?q?=5Fmax=5Fattempts=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CONFIGURATION.md | 68 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 29adbf1..9f6d5af 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -139,10 +139,18 @@ because moving a live database during an upgrade is unsafe. process-local Kiro request queue until the upstream response completes. Override with `KIRO_STREAM_BUFFER_UNTIL_COMPLETE`. - `stream_max_attempts`: Maximum complete event-stream attempts (`1`-`10`, - default: `3`). In normal live-stream mode, retries remain limited to failures - before semantic output. With `stream_buffer_until_complete` enabled, this - limit also covers failures after upstream output because none of that attempt - has reached OpenCode yet. Override with `KIRO_STREAM_MAX_ATTEMPTS`. + default: `3`). This caps the total SDK sends for **one** inbound provider + request — the initial send plus any pre-output stream retries — so `3` means + at most three `generateAssistantResponse` calls for that request. It is + distinct from `max_request_iterations`, which bounds the overall per-request + loop that also covers HTTP-error retries and account switches. Both budgets + apply at the same time: a stream retry is refused once `stream_max_attempts` + is reached even if loop iterations remain, and the loop still stops at + `max_request_iterations` regardless of remaining stream attempts. In normal + live-stream mode, retries remain limited to failures before semantic output. + With `stream_buffer_until_complete` enabled, this limit also covers failures + after upstream output because none of that attempt has reached OpenCode yet. + Override with `KIRO_STREAM_MAX_ATTEMPTS`. - `token_expiry_buffer_ms`: Token refresh buffer time (30000-300000ms, default: `300000`). An access token within this window of expiry is treated as expired and refreshed on next use. @@ -210,6 +218,58 @@ settings can also be overridden with `KIRO_LOG_RETENTION_DAYS`, `KIRO_LOG_MAX_TOTAL_SIZE_MB`, `KIRO_LOG_COMPRESS_AFTER_DAYS`, and `KIRO_LOG_SEGMENT_SIZE_MB`. +## Stream observability logging + +Stream health is tracked in `plugin.log` independently from +`enable_log_api_request`, so you can measure upstream stream failures without +recording prompt or tool payloads. Two records anchor that measurement, and +every stream log line carries a fixed set of volume-only fields. + +**`Kiro stream request started`** (INFO) is written exactly once per inbound +streaming request, unconditionally — it does not depend on +`enable_log_api_request`, and non-streaming requests are not recorded. Fields: +`conversationId`, `model`, `effectiveModel`, `processId`. This is the +denominator every stream-failure rate is measured against, so an account switch +or HTTP-error retry inside the same inbound request still produces only one +record. The string is a grep target for log-analysis scripts; it is exported as +`STREAM_REQUEST_STARTED_LOG` from `src/core/request/request-handler.ts` and is +treated as a stable contract. + +**`Kiro stream ended without completion metadata`** (WARN, exported as +`STREAM_MISSING_COMPLETION_LOG`, `outcome: +'clean_eof_without_completion_metadata'`) fires when the upstream event stream +ends cleanly but never sent completion metadata. The response still completes +normally, exactly as before — this record adds no behavior change, it only makes +a case visible that previously left no trace at all. A rising count here means +upstream is closing streams early without erroring, which is worth watching even +though nothing fails today. + +Every stream log record — the clean-EOF warning above and each stream failure +outcome (`retrying`, `exhausted`, `terminated_after_output`, +`ignored_after_completion_metadata`, `recovered`) — carries these shared fields: + +| Field | Meaning | +| ------------------------------------------- | -------------------------------------------------------------------------------- | +| `conversationId`, `model`, `effectiveModel` | Request identity and the resolved wire model | +| `region`, `account`, `accountId` | Which account and region served the attempt | +| `streamAttempt`, `maxStreamAttempts` | Attempt number and the `stream_max_attempts` cap | +| `streamDeliveryMode` | `buffered` or `live`, from `stream_buffer_until_complete` | +| `sdkHttpKeepAlive` | The effective `sdk_http_keep_alive` value | +| `processId`, `bunVersion` | OS process id and the Bun runtime version | +| `upstreamEventCount` | Raw upstream events seen in this attempt | +| `streamElapsedMs` | Wall time from the start of this stream attempt | +| `emittedReasoningChars` | Character count of reasoning text already emitted | +| `emittedVisibleChars` | Character count of visible reply text already emitted | +| `emittedToolCount` | Number of tool calls already emitted | +| `sawToolIntent` | Whether upstream showed tool intent, including a partial or discarded tool event | + +The last four are lengths, counts, and a boolean only. No reasoning text, reply +text, or tool arguments are ever written to these records — a character count +cannot reconstruct content. They exist so a failed attempt can be classified +after the fact: an attempt with zero emitted characters, zero tool calls, and no +tool intent is safe to reason about differently from one that already put output +in front of you. + ## Account distribution across processes If you run several OpenCode processes at once (multiple terminals, editor From dbc85f2ab54c24ec4bdd39e56b438038d31c14dd Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 13:01:17 +0800 Subject: [PATCH 05/21] =?UTF-8?q?feat(request):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B5=81=E6=81=A2=E5=A4=8D=E5=8D=8F=E8=B0=83=E5=99=A8=EF=BC=88?= =?UTF-8?q?=E8=B7=A8=20attempt=20=E5=8D=95=E4=B8=80=20SSE=20=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E=20Tier=20=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/stream-recovery.fixture.ts | 137 +++++++++++ src/__tests__/stream-recovery.test.ts | 219 +++++++++++++++++ src/core/request/stream-recovery.ts | 285 +++++++++++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 src/__tests__/stream-recovery.fixture.ts create mode 100644 src/__tests__/stream-recovery.test.ts create mode 100644 src/core/request/stream-recovery.ts diff --git a/src/__tests__/stream-recovery.fixture.ts b/src/__tests__/stream-recovery.fixture.ts new file mode 100644 index 0000000..4a54dff --- /dev/null +++ b/src/__tests__/stream-recovery.fixture.ts @@ -0,0 +1,137 @@ +import { expect } from 'bun:test' +import { + StreamRecoveryCoordinator, + type AttemptHandle, + type AttemptObservation, + type StreamRecoveryCompletion, + type StreamRecoveryMode +} from '../core/request/stream-recovery.js' + +export class TestStreamFailure extends Error { + override readonly name = 'TestStreamFailure' +} + +type AttemptSpec = { + readonly output: readonly unknown[] + readonly observation?: AttemptObservation + readonly failure?: Error +} + +type HarnessOverrides = { + readonly mode?: StreamRecoveryMode + readonly maxAttempts?: number + readonly signal?: AbortSignal + readonly delayFn?: (attemptIndex: number, signal: AbortSignal) => Promise + readonly mapError?: (failure: unknown) => Error +} + +export const EMPTY_OBSERVATION: AttemptObservation = { + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: false +} + +export const ELIGIBLE = { + mode: 'reasoning_restart', + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: false +} as const + +export function chunk( + label: string, + delta: Readonly> = {}, + finishReason: string | null = null +): unknown { + return { label, choices: [{ delta, finish_reason: finishReason }] } +} + +async function* outputThenFailure(spec: AttemptSpec): AsyncGenerator { + for (const value of spec.output) yield value + if (spec.failure) throw spec.failure +} + +export function makeAttempt(spec: AttemptSpec): AttemptHandle { + const chunks = outputThenFailure(spec) + return { + chunks, + observed: () => spec.observation ?? EMPTY_OBSERVATION, + close: async () => { + await chunks.return(undefined) + } + } +} + +export function reasoningFailure(message: string, label = 'reasoning'): AttemptHandle { + return makeAttempt({ + output: [chunk(label, { reasoning_content: 'partial' })], + failure: new TestStreamFailure(message) + }) +} + +function labelOf(value: unknown): string { + if (typeof value === 'object' && value !== null && 'label' in value) { + const label = value.label + if (typeof label === 'string') return label + } + throw new TypeError('Test chunk has no label') +} + +export function createHarness( + attempts: readonly AttemptHandle[], + overrides: HarnessOverrides = {} +) { + const requestedAttempts: number[] = [] + const completions: StreamRecoveryCompletion[] = [] + let terminalCalls = 0 + const signal = overrides.signal ?? new AbortController().signal + const coordinator = new StreamRecoveryCoordinator({ + mode: overrides.mode ?? 'reasoning_restart', + maxAttempts: overrides.maxAttempts ?? attempts.length, + signal, + attemptFactory: async (attemptIndex) => { + requestedAttempts.push(attemptIndex) + const attempt = attempts[attemptIndex - 1] + if (!attempt) throw new RangeError(`No attempt ${attemptIndex}`) + return attempt + }, + delayFn: overrides.delayFn ?? (async () => {}), + mapError: + overrides.mapError ?? + ((failure) => new TestStreamFailure('mapped stream failure', { cause: failure })), + encodeChunk: (value) => new TextEncoder().encode(labelOf(value)), + onComplete: (completion) => { + completions.push(completion) + }, + onTerminal: () => { + terminalCalls++ + } + }) + return { + coordinator, + requestedAttempts, + completions, + terminalCalls: () => terminalCalls + } +} + +export async function collect(stream: ReadableStream): Promise { + const reader = stream.getReader() + const labels: string[] = [] + while (true) { + const item = await reader.read() + if (item.done) return labels + labels.push(new TextDecoder().decode(item.value)) + } +} + +export async function expectRejection(promise: Promise, expected: Error): Promise { + try { + await promise + } catch (failure) { + if (failure instanceof Error) { + expect(failure).toBe(expected) + return + } + throw new TestStreamFailure('Promise rejected with a non-Error value', { cause: failure }) + } + throw new TestStreamFailure('Expected promise rejection') +} diff --git a/src/__tests__/stream-recovery.test.ts b/src/__tests__/stream-recovery.test.ts new file mode 100644 index 0000000..b1c3290 --- /dev/null +++ b/src/__tests__/stream-recovery.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test' +import { decideRecoveryTier, type AttemptHandle } from '../core/request/stream-recovery.js' +import { + ELIGIBLE, + EMPTY_OBSERVATION, + TestStreamFailure, + chunk, + collect, + createHarness, + expectRejection, + makeAttempt, + reasoningFailure +} from './stream-recovery.fixture.js' + +describe('decideRecoveryTier', () => { + test('returns reasoning_restart when recovery is enabled and no visible or tool output exists', () => { + // Given + const input = ELIGIBLE + // When / Then + expect(decideRecoveryTier(input)).toBe('reasoning_restart') + }) + + test('returns none for disabled mode, visible output, emitted tools, or raw tool intent', () => { + // Given + const unsafe = [ + { ...ELIGIBLE, mode: 'off' }, + { ...ELIGIBLE, emitted: { visibleChars: 1, toolCount: 0 } }, + { ...ELIGIBLE, emitted: { visibleChars: 0, toolCount: 1 } }, + { ...ELIGIBLE, sawToolIntent: true } + ] as const + // When + const tiers = unsafe.map(decideRecoveryTier) + // Then + expect(tiers).toEqual(['none', 'none', 'none', 'none']) + }) +}) + +describe('StreamRecoveryCoordinator', () => { + test('continues reasoning in one stream and publishes only the successful terminal sequence', async () => { + // Given + const first = makeAttempt({ + output: [ + chunk('reasoning-1', { reasoning_content: 'first' }), + chunk('failed-finish', {}, 'stop'), + chunk('failed-after-finish') + ], + failure: new TestStreamFailure('first attempt failed') + }) + const second = makeAttempt({ + output: [chunk('reasoning-2', { reasoning_content: 'second' }), chunk('finish', {}, 'stop')] + }) + const harness = createHarness([first, second]) + // When + const labels = await collect(harness.coordinator.stream) + // Then + expect(labels).toEqual(['reasoning-1', 'reasoning-2', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2]) + expect(harness.completions).toEqual([{ attemptIndex: 2, recovered: true }]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('terminates with the mapped error after visible text and requests no recovery', async () => { + // Given + const mapped = new TestStreamFailure('visible output cannot restart') + const attempt = makeAttempt({ + output: [chunk('visible', { content: 'answer' })], + observation: { emitted: { visibleChars: 6, toolCount: 0 }, sawToolIntent: false }, + failure: new TestStreamFailure('failed after text') + }) + const harness = createHarness([attempt], { maxAttempts: 3, mapError: () => mapped }) + const reader = harness.coordinator.stream.getReader() + // When / Then + expect(new TextDecoder().decode((await reader.read()).value)).toBe('visible') + await expectRejection(reader.read(), mapped) + expect(harness.requestedAttempts).toEqual([1]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('raw tool intent makes a reasoning-only failure ineligible', async () => { + // Given + const mapped = new TestStreamFailure('tool intent cannot restart') + const attempt = makeAttempt({ + output: [chunk('reasoning', { reasoning_content: 'thinking' })], + observation: { emitted: { visibleChars: 0, toolCount: 0 }, sawToolIntent: true }, + failure: new TestStreamFailure('failed after tool intent') + }) + const harness = createHarness([attempt], { maxAttempts: 3, mapError: () => mapped }) + const reader = harness.coordinator.stream.getReader() + // When / Then + await reader.read() + await expectRejection(reader.read(), mapped) + expect(harness.requestedAttempts).toEqual([1]) + }) + + test('maps only the last failure when the total attempt budget is exhausted', async () => { + // Given + const failures = [new TestStreamFailure('one'), new TestStreamFailure('two')] + const mapped = new TestStreamFailure('last failure', { cause: failures[1] }) + const harness = createHarness( + failures.map((failure, index) => + makeAttempt({ + output: [chunk(`reasoning-${index + 1}`, { reasoning_content: 'x' })], + failure + }) + ), + { maxAttempts: 2, mapError: (failure) => (failure === failures[1] ? mapped : new Error()) } + ) + const reader = harness.coordinator.stream.getReader() + // When / Then + await reader.read() + await reader.read() + await expectRejection(reader.read(), mapped) + expect(harness.requestedAttempts).toEqual([1, 2]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('abort during backoff errors immediately and prevents another factory call', async () => { + // Given + const controller = new AbortController() + const backoffStarted = Promise.withResolvers() + const harness = createHarness([reasoningFailure('one')], { + maxAttempts: 3, + signal: controller.signal, + delayFn: (_attemptIndex, signal) => + new Promise((_resolve, reject) => { + backoffStarted.resolve() + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + const reader = harness.coordinator.stream.getReader() + await reader.read() + const pendingRead = reader.read() + await backoffStarted.promise + // When + const reason = new DOMException('cancelled in backoff', 'AbortError') + controller.abort(reason) + // Then + await expectRejection(pendingRead, reason) + expect(harness.requestedAttempts).toEqual([1]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('abort during an upstream pull errors immediately and closes the active attempt', async () => { + // Given + const controller = new AbortController() + const pullStarted = Promise.withResolvers() + let closeCalls = 0 + const attempt: AttemptHandle = { + chunks: { + next: () => { + pullStarted.resolve() + return new Promise>(() => {}) + } + }, + observed: () => EMPTY_OBSERVATION, + close: async () => { + closeCalls++ + } + } + const harness = createHarness([attempt], { maxAttempts: 3, signal: controller.signal }) + const pendingRead = harness.coordinator.stream.getReader().read() + await pullStarted.promise + // When + const reason = new DOMException('cancelled in pull', 'AbortError') + controller.abort(reason) + // Then + await expectRejection(pendingRead, reason) + await Promise.resolve() + expect(closeCalls).toBe(1) + expect(harness.requestedAttempts).toEqual([1]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('mode off maps the first failure without a recovery attempt', async () => { + // Given + const mapped = new TestStreamFailure('recovery disabled') + const attempt = makeAttempt({ output: [], failure: new TestStreamFailure('off') }) + const harness = createHarness([attempt], { + mode: 'off', + maxAttempts: 3, + mapError: () => mapped + }) + // When / Then + await expectRejection(collect(harness.coordinator.stream), mapped) + expect(harness.requestedAttempts).toEqual([1]) + }) + + test('an empty recovery attempt completes the same stream cleanly', async () => { + // Given + const first = reasoningFailure('retry') + const harness = createHarness([first, makeAttempt({ output: [] })]) + // When + const labels = await collect(harness.coordinator.stream) + // Then + expect(labels).toEqual(['reasoning']) + expect(harness.completions).toEqual([{ attemptIndex: 2, recovered: true }]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('cumulative visible output on the second failure prevents a third attempt', async () => { + // Given + const lastFailure = new TestStreamFailure('failed after recovered text') + const mapped = new TestStreamFailure('cumulative output blocks retry', { cause: lastFailure }) + const first = reasoningFailure('first') + const second = makeAttempt({ + output: [chunk('visible', { content: 'answer' })], + observation: { emitted: { visibleChars: 6, toolCount: 0 }, sawToolIntent: false }, + failure: lastFailure + }) + const harness = createHarness([first, second], { maxAttempts: 3, mapError: () => mapped }) + const reader = harness.coordinator.stream.getReader() + // When / Then + expect(new TextDecoder().decode((await reader.read()).value)).toBe('reasoning') + expect(new TextDecoder().decode((await reader.read()).value)).toBe('visible') + await expectRejection(reader.read(), mapped) + expect(harness.requestedAttempts).toEqual([1, 2]) + expect(harness.terminalCalls()).toBe(1) + }) +}) diff --git a/src/core/request/stream-recovery.ts b/src/core/request/stream-recovery.ts new file mode 100644 index 0000000..55f5397 --- /dev/null +++ b/src/core/request/stream-recovery.ts @@ -0,0 +1,285 @@ +/** + * Coordinates one outbound SSE byte stream across transformed OpenAI-chunk iterators from + * multiple SDK attempts. Attempts stay pre-SSE-encoding so each attempt keeps its own + * transformer, EmittedOutputAccumulator, and StreamObserver; the caller injects the existing + * SSE encoder at the sole publication point. A terminal chunk and every chunk after it are + * withheld until that attempt drains cleanly, preventing failed attempts from publishing a + * synthetic success before recovery starts. + */ + +export type StreamRecoveryMode = 'off' | 'reasoning_restart' + +export type RecoveryTier = 'reasoning_restart' | 'none' + +export type RecoveryDecisionInput = { + readonly mode: StreamRecoveryMode + readonly emitted: { + readonly visibleChars: number + readonly toolCount: number + } + readonly sawToolIntent: boolean +} + +export type AttemptObservation = { + readonly emitted: { + readonly visibleChars: number + readonly toolCount: number + } + readonly sawToolIntent: boolean +} + +export type AttemptHandle = { + readonly chunks: AsyncIterator + readonly observed: () => AttemptObservation + readonly close: () => Promise +} + +export type AttemptFactory = (attemptIndex: number) => Promise + +export type StreamRecoveryCompletion = { + /** One-based index of the attempt that drained successfully. */ + readonly attemptIndex: number + readonly recovered: boolean +} + +export type StreamRecoveryOptions = { + readonly mode: StreamRecoveryMode + readonly maxAttempts: number + readonly signal: AbortSignal + readonly attemptFactory: AttemptFactory + /** Receives the one-based index of the failed attempt being backed off. */ + readonly delayFn: (attemptIndex: number, signal: AbortSignal) => Promise + readonly mapError: (failure: unknown) => Error + readonly encodeChunk: (chunk: unknown) => Uint8Array + readonly onComplete: (completion: StreamRecoveryCompletion) => void | Promise + readonly onTerminal: () => void + readonly onCancel?: (reason: unknown) => void +} + +export function decideRecoveryTier(input: RecoveryDecisionInput): RecoveryTier { + switch (input.mode) { + case 'off': + return 'none' + case 'reasoning_restart': + return input.emitted.visibleChars === 0 && + input.emitted.toolCount === 0 && + !input.sawToolIntent + ? 'reasoning_restart' + : 'none' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isTerminalChunk(chunk: unknown): boolean { + if (!isRecord(chunk)) return false + const choices = chunk['choices'] + if (!Array.isArray(choices)) return false + const first = choices[0] + if (!isRecord(first)) return false + return first['finish_reason'] !== null && first['finish_reason'] !== undefined +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The request was aborted', 'AbortError') +} + +function errorFrom(failure: unknown): Error { + return failure instanceof Error + ? failure + : new TypeError('Stream attempt rejected with a non-Error value', { cause: failure }) +} + +export class StreamRecoveryCoordinator { + readonly stream: ReadableStream + + private readonly options: StreamRecoveryOptions + private activeAttempt: AttemptHandle | undefined + private attemptIndex = 0 + private visibleChars = 0 + private toolCount = 0 + private sawToolIntent = false + private terminal = false + private completionFired = false + private abortListener: (() => void) | undefined + private readonly pendingTerminalChunks: unknown[] = [] + + constructor(options: StreamRecoveryOptions) { + if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) { + throw new RangeError('maxAttempts must be a positive integer') + } + this.options = options + this.stream = new ReadableStream( + { + start: (controller) => this.start(controller), + pull: (controller) => this.pull(controller), + cancel: (reason) => this.cancel(reason) + }, + { highWaterMark: 0 } + ) + } + + private start(controller: ReadableStreamDefaultController): void { + this.abortListener = () => { + if (this.terminal) return + const reason = abortReason(this.options.signal) + this.finish() + controller.error(reason) + void this.closeActiveAttempt() + } + + if (this.options.signal.aborted) this.abortListener() + else this.options.signal.addEventListener('abort', this.abortListener, { once: true }) + } + + private async pull(controller: ReadableStreamDefaultController): Promise { + if (this.terminal) return + try { + await this.publishNext(controller) + } catch (failure) { + const error = failure instanceof Error ? failure : errorFrom(failure) + if (this.terminal) return + await this.closeActiveAttempt() + if (this.terminal) return + this.finish() + controller.error(this.options.signal.aborted ? abortReason(this.options.signal) : error) + } + } + + private async publishNext( + controller: ReadableStreamDefaultController + ): Promise { + while (!this.terminal) { + if (this.options.signal.aborted) throw abortReason(this.options.signal) + + if (!this.activeAttempt) { + const ready = await this.openAttemptOrRecover(controller) + if (!ready) return + } + + const attempt = this.activeAttempt + if (!attempt) continue + + let item: IteratorResult + try { + item = await attempt.chunks.next() + } catch (failure) { + const error = failure instanceof Error ? failure : errorFrom(failure) + if (!(await this.recoverOrTerminate(error, attempt, controller))) return + continue + } + + if (this.terminal) return + if (item.done) { + await this.complete(controller) + return + } + if (this.pendingTerminalChunks.length > 0 || isTerminalChunk(item.value)) { + this.pendingTerminalChunks.push(item.value) + continue + } + + controller.enqueue(this.options.encodeChunk(item.value)) + return + } + } + + private async openAttemptOrRecover( + controller: ReadableStreamDefaultController + ): Promise { + this.attemptIndex++ + try { + const attempt = await this.options.attemptFactory(this.attemptIndex) + if (this.terminal || this.options.signal.aborted) { + await Promise.allSettled([attempt.close()]) + return false + } + this.activeAttempt = attempt + return true + } catch (failure) { + const error = failure instanceof Error ? failure : errorFrom(failure) + return this.recoverOrTerminate(error, undefined, controller) + } + } + + private async recoverOrTerminate( + failure: Error, + failedAttempt: AttemptHandle | undefined, + controller: ReadableStreamDefaultController + ): Promise { + if (this.terminal) return false + if (failedAttempt) this.mergeObservation(failedAttempt.observed()) + this.pendingTerminalChunks.length = 0 + await this.closeActiveAttempt() + if (this.terminal) return false + + const tier = decideRecoveryTier({ + mode: this.options.mode, + emitted: { visibleChars: this.visibleChars, toolCount: this.toolCount }, + sawToolIntent: this.sawToolIntent + }) + if (tier === 'none' || this.attemptIndex >= this.options.maxAttempts) { + this.finish() + controller.error(this.options.mapError(failure)) + return false + } + + await this.options.delayFn(this.attemptIndex, this.options.signal) + return !this.terminal + } + + private mergeObservation(observation: AttemptObservation): void { + this.visibleChars += observation.emitted.visibleChars + this.toolCount += observation.emitted.toolCount + this.sawToolIntent ||= observation.sawToolIntent + } + + private async complete(controller: ReadableStreamDefaultController): Promise { + const succeededAttempt = this.attemptIndex + await this.closeActiveAttempt() + if (this.terminal) return + + if (!this.completionFired) { + this.completionFired = true + await this.options.onComplete({ + attemptIndex: succeededAttempt, + recovered: succeededAttempt > 1 + }) + } + if (this.terminal) return + + for (const chunk of this.pendingTerminalChunks) { + controller.enqueue(this.options.encodeChunk(chunk)) + } + this.pendingTerminalChunks.length = 0 + this.finish() + controller.close() + } + + private async cancel(reason: unknown): Promise { + if (this.terminal) return + const closing = this.closeActiveAttempt() + this.finish() + this.options.onCancel?.(reason) + await closing + } + + private async closeActiveAttempt(): Promise { + const attempt = this.activeAttempt + this.activeAttempt = undefined + if (!attempt) return + await Promise.allSettled([attempt.close()]) + } + + private finish(): void { + if (this.terminal) return + this.terminal = true + if (this.abortListener) { + this.options.signal.removeEventListener('abort', this.abortListener) + this.abortListener = undefined + } + this.options.onTerminal() + } +} From 59730c429d0921226fc3cc5443bec04af54b2137 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 13:10:04 +0800 Subject: [PATCH 06/21] =?UTF-8?q?feat(config):=20=E6=96=B0=E5=A2=9E=20stre?= =?UTF-8?q?am=5Frecovery=5Fmode=20=E5=B9=B6=E4=B8=BA=E6=B5=81=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=20503=20=E8=A1=A5=20Retry-After?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/config-backfill.test.ts | 10 +++- src/__tests__/config-loader.test.ts | 37 +++++++++++- src/__tests__/stream-error.test.ts | 81 +++++++++++++++++++++++++++ src/core/request/stream-error.ts | 9 ++- src/plugin/config/loader.ts | 5 ++ src/plugin/config/schema.ts | 19 +++++++ 6 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/stream-error.test.ts diff --git a/src/__tests__/config-backfill.test.ts b/src/__tests__/config-backfill.test.ts index cc75419..6d9160d 100644 --- a/src/__tests__/config-backfill.test.ts +++ b/src/__tests__/config-backfill.test.ts @@ -68,6 +68,7 @@ describe('config backfill: additive new-key insertion', () => { expect(written.token_keepalive_enabled).toBe(false) expect(written.token_keepalive_interval_ms).toBe(600000) expect(written.auto_sync_kiro_cli).toBe(false) + expect(written.stream_recovery_mode).toBe('off') // every DEFAULT_CONFIG key is now present for (const key of Object.keys(DEFAULT_CONFIG)) { expect(key in written).toBe(true) @@ -93,13 +94,20 @@ describe('config backfill: value-preservation guarantees', () => { test('never flips an explicit false to the default', () => { // token_keepalive_enabled default is false; set it TRUE explicitly and ensure // backfill does not touch it. auto_sync default is false; set TRUE explicitly. - writeUserConfigRaw(JSON.stringify({ token_keepalive_enabled: true, auto_sync_kiro_cli: true })) + writeUserConfigRaw( + JSON.stringify({ + token_keepalive_enabled: true, + auto_sync_kiro_cli: true, + stream_recovery_mode: 'reasoning_restart' + }) + ) loadConfig(projectDir) const written = JSON.parse(readUser()) expect(written.token_keepalive_enabled).toBe(true) expect(written.auto_sync_kiro_cli).toBe(true) + expect(written.stream_recovery_mode).toBe('reasoning_restart') }) test('preserves unknown/custom keys not in the schema', () => { diff --git a/src/__tests__/config-loader.test.ts b/src/__tests__/config-loader.test.ts index eb146ba..4c82f12 100644 --- a/src/__tests__/config-loader.test.ts +++ b/src/__tests__/config-loader.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { StreamRecoveryMode as CoordinatorStreamRecoveryMode } from '../core/request/stream-recovery.js' import { loadConfig } from '../plugin/config/loader.js' -import { DEFAULT_CONFIG } from '../plugin/config/schema.js' +import { DEFAULT_CONFIG, KiroConfigSchema } from '../plugin/config/schema.js' import { getUserConfigPath } from '../plugin/paths.js' // loadConfig reads: @@ -25,6 +26,7 @@ const KIRO_ENV_KEYS = [ 'KIRO_STREAM_EVENT_TIMEOUT_ENABLED', 'KIRO_STREAM_BUFFER_UNTIL_COMPLETE', 'KIRO_STREAM_MAX_ATTEMPTS', + 'KIRO_STREAM_RECOVERY_MODE', 'KIRO_SDK_RESPONSE_TIMEOUT_ENABLED', 'KIRO_SDK_RESPONSE_TIMEOUT_MS', 'KIRO_SDK_HTTP_KEEP_ALIVE', @@ -100,6 +102,7 @@ describe('loadConfig defaults', () => { expect(cfg.stream_event_timeout_enabled).toBe(false) expect(cfg.stream_buffer_until_complete).toBe(false) expect(cfg.stream_max_attempts).toBe(3) + expect(cfg.stream_recovery_mode).toBe('off') expect(cfg.sdk_response_timeout_enabled).toBe(false) expect(cfg.sdk_response_timeout_ms).toBe(300000) expect(cfg.sdk_http_keep_alive).toBe(false) @@ -112,6 +115,14 @@ describe('loadConfig defaults', () => { expect(cfg.log_compress_after_days).toBe(1) expect(cfg.log_segment_size_mb).toBe(16) }) + + test('the zod default and the DEFAULT_CONFIG literal agree on the recovery mode', () => { + // loadConfig only ever runs KiroConfigSchema.partial(), which strips zod + // defaults, so DEFAULT_CONFIG is the operative default and the two sources + // can drift apart silently. + expect(KiroConfigSchema.parse({}).stream_recovery_mode).toBe('off') + expect(DEFAULT_CONFIG.stream_recovery_mode).toBe('off') + }) }) describe('loadConfig env overrides', () => { @@ -168,6 +179,16 @@ describe('loadConfig env overrides', () => { expect(loadConfig(projectDir).stream_buffer_until_complete).toBe(true) }) + test('KIRO_STREAM_RECOVERY_MODE selects a recovery strategy', () => { + process.env.KIRO_STREAM_RECOVERY_MODE = 'reasoning_restart' + expect(loadConfig(projectDir).stream_recovery_mode).toBe('reasoning_restart') + }) + + test('invalid recovery mode env falls back to off (schema .catch)', () => { + process.env.KIRO_STREAM_RECOVERY_MODE = 'exact_replay' + expect(loadConfig(projectDir).stream_recovery_mode).toBe('off') + }) + test('number env overrides parse numerically', () => { process.env.KIRO_QUOTA_RESERVE_THRESHOLD = '0.5' process.env.KIRO_RATE_LIMIT_MAX_RETRIES = '7' @@ -260,4 +281,18 @@ describe('loadConfig file merge', () => { const cfg = loadConfig(projectDir) expect(cfg.quota_reserve_threshold).toBe(DEFAULT_CONFIG.quota_reserve_threshold) }) + + test('user file overrides the recovery mode; an out-of-enum value is rejected', () => { + writeUserConfig({ stream_recovery_mode: 'reasoning_restart' }) + expect(loadConfig(projectDir).stream_recovery_mode).toBe('reasoning_restart') + + writeUserConfig({ stream_recovery_mode: 'exact_replay' }) + expect(loadConfig(projectDir).stream_recovery_mode).toBe('off') + }) + + test('the config literal union stays assignable to the coordinator mode', () => { + const forCoordinator: CoordinatorStreamRecoveryMode = + loadConfig(projectDir).stream_recovery_mode + expect(forCoordinator).toBe('off') + }) }) diff --git a/src/__tests__/stream-error.test.ts b/src/__tests__/stream-error.test.ts new file mode 100644 index 0000000..88e3503 --- /dev/null +++ b/src/__tests__/stream-error.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test' +import { UpstreamUnexpectedError } from '../core/request/stream-error.js' + +// The heuristics pinned in the second describe live in opencode's +// src/session/retry.ts:127-150 (fallback retry classification for a plain Error +// that is neither ContextOverflowError nor APIError). If our terminal message +// ever matched one of them the host would silently replay a turn that already +// emitted output — duplicating text, tool calls, and quota. +const HOST_RETRY_TEXT_HEURISTICS = ['rate limit', 'too many requests', 'rate increased too quickly'] + +describe('UpstreamUnexpectedError.toResponse', () => { + test('returns a 503 carrying Retry-After alongside Content-Type', () => { + const response = new UpstreamUnexpectedError(new Error('boom'), false).toResponse() + + expect(response.status).toBe(503) + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('retry-after')).toBe('2') + }) + + test('keeps the pre-output payload shape unchanged', async () => { + const response = new UpstreamUnexpectedError(new Error('boom'), false).toResponse() + + expect(await response.json()).toEqual({ + retryable: true, + phase: 'stream', + emittedOutput: false, + code: 'UPSTREAM_UNEXPECTED' + }) + }) + + test('reports emittedOutput from the constructed error', async () => { + const response = new UpstreamUnexpectedError(new Error('boom'), true).toResponse() + + expect(await response.json()).toMatchObject({ emittedOutput: true }) + }) +}) + +describe('UpstreamUnexpectedError message must not trip host retry heuristics', () => { + test('is the exact terminal message', () => { + expect(new UpstreamUnexpectedError(new Error('boom'), true).message).toBe( + 'Kiro upstream event stream failed unexpectedly' + ) + }) + + test('contains none of the host rate-limit text heuristics, case-insensitively', () => { + const lowered = new UpstreamUnexpectedError(new Error('boom'), true).message.toLowerCase() + + for (const heuristic of HOST_RETRY_TEXT_HEURISTICS) { + expect(lowered).not.toContain(heuristic) + } + }) + + test('is not JSON the host would classify as retryable', () => { + const message = new UpstreamUnexpectedError(new Error('boom'), true).message + + expect(matchesHostJsonRetryHeuristic(message)).toBe(false) + }) + + test('the JSON heuristic mirror does fire on the shapes the host retries', () => { + expect(matchesHostJsonRetryHeuristic('{"code":"resource_exhausted"}')).toBe(true) + expect(matchesHostJsonRetryHeuristic('{"code":"service_unavailable"}')).toBe(true) + expect( + matchesHostJsonRetryHeuristic('{"type":"error","error":{"type":"too_many_requests"}}') + ).toBe(true) + }) +}) + +function matchesHostJsonRetryHeuristic(message: string): boolean { + let parsed: unknown + try { + parsed = JSON.parse(message) + } catch { + return false + } + + if (typeof parsed !== 'object' || parsed === null) return false + + const record = parsed as { code?: unknown; type?: unknown } + const code = typeof record.code === 'string' ? record.code : '' + return code.includes('exhausted') || code.includes('unavailable') || record.type === 'error' +} diff --git a/src/core/request/stream-error.ts b/src/core/request/stream-error.ts index 874c114..8771ccf 100644 --- a/src/core/request/stream-error.ts +++ b/src/core/request/stream-error.ts @@ -36,9 +36,16 @@ export class UpstreamUnexpectedError extends Error { } toResponse(): Response { + // Retry-After is load-bearing, not advisory. opencode's retry.ts takes the + // RETRY_MAX_DELAY (2^31) cap — not the 30s no-headers cap — for any error + // that carries response headers, and its retry count is unbounded, so a 503 + // without Retry-After grows the host backoff 2s -> 4s -> ... -> 1024s+. + // Present, it pins backoff to this constant. 2s rounds the plugin's own + // stream retry backoff base (250/500ms + jitter) up to the host's + // whole-second Retry-After granularity. return new Response(JSON.stringify(this.toPayload()), { status: 503, - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json', 'Retry-After': '2' } }) } } diff --git a/src/plugin/config/loader.ts b/src/plugin/config/loader.ts index 5a25bc5..22efd91 100644 --- a/src/plugin/config/loader.ts +++ b/src/plugin/config/loader.ts @@ -7,6 +7,7 @@ import { DEFAULT_CONFIG, KiroConfigSchema, RegionSchema, + StreamRecoveryModeSchema, type KiroConfig } from './schema' @@ -215,6 +216,10 @@ function applyEnvOverrides(config: KiroConfig): KiroConfig { stream_max_attempts: parseNumberEnv(env.KIRO_STREAM_MAX_ATTEMPTS, config.stream_max_attempts), + stream_recovery_mode: env.KIRO_STREAM_RECOVERY_MODE + ? StreamRecoveryModeSchema.catch('off').parse(env.KIRO_STREAM_RECOVERY_MODE) + : config.stream_recovery_mode, + token_expiry_buffer_ms: parseNumberEnv( env.KIRO_TOKEN_EXPIRY_BUFFER_MS, config.token_expiry_buffer_ms diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index 658d1c6..1e07df0 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -14,6 +14,17 @@ export type AccountSelectionStrategy = z.infer +/** + * Recovery strategy applied when an upstream event stream fails after output. + * - off: no recovery; behavior is byte-for-byte identical to pre-recovery builds + * - reasoning_restart: restart the turn from accumulated reasoning instead of + * replaying already-emitted content + * The literal strings must stay identical to `StreamRecoveryMode` in + * src/core/request/stream-recovery.ts (the coordinator consumes this value). + */ +export const StreamRecoveryModeSchema = z.enum(['off', 'reasoning_restart']) +export type StreamRecoveryMode = z.infer + export const RegionSchema = z.enum([ 'us-east-1', 'us-east-2', @@ -153,6 +164,13 @@ export const KiroConfigSchema = z.object({ */ stream_max_attempts: z.number().int().min(1).max(10).default(3), + /** + * Recovery strategy for an upstream event stream that fails after output. + * Defaults to 'off' during Phase 1 rollout; the default flips to + * 'reasoning_restart' only after Phase 1 acceptance. + */ + stream_recovery_mode: StreamRecoveryModeSchema.default('off'), + token_expiry_buffer_ms: z.number().min(30000).max(300000).default(300000), /** @@ -242,6 +260,7 @@ export const DEFAULT_CONFIG: KiroConfig = { request_timeout_ms: 120000, stream_buffer_until_complete: false, stream_max_attempts: 3, + stream_recovery_mode: 'off', token_expiry_buffer_ms: 300000, token_keepalive_enabled: false, token_keepalive_interval_ms: 600000, From 6185fbb750da0c4fec61038c28b89e1eaaa2d487 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 13:34:11 +0800 Subject: [PATCH 07/21] =?UTF-8?q?feat(request):=20live=20=E6=B5=81?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=90=8E=E5=A4=B1=E8=B4=A5=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20Tier=20A=20reasoning-only=20=E6=81=A2=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E5=AE=89=E5=85=A8=E9=97=B8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/request-handler.test.ts | 586 ++++++++++++++++++++++- src/core/request/recovery-attempt.ts | 301 ++++++++++++ src/core/request/recovery-integration.ts | 100 ++++ src/core/request/request-handler.ts | 130 ++++- src/core/request/response-handler.ts | 146 +++++- src/core/request/stream-log-events.ts | 5 + src/core/request/stream-recovery.ts | 6 + 7 files changed, 1253 insertions(+), 21 deletions(-) create mode 100644 src/core/request/recovery-attempt.ts create mode 100644 src/core/request/recovery-integration.ts create mode 100644 src/core/request/stream-log-events.ts diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index e1e8eb7..476281e 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1,14 +1,16 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' import { TokenRefresher } from '../core/auth/token-refresher.js' import { RequestHandler, STREAM_MISSING_COMPLETION_LOG, STREAM_REQUEST_STARTED_LOG } from '../core/request/request-handler.js' -import { ResponseHandler } from '../core/request/response-handler.js' +import { ResponseHandler, type SdkResponseLifecycle } from '../core/request/response-handler.js' +import { SdkEventStreamIterationError } from '../core/request/stream-error.js' import { encodeRefreshToken } from '../kiro/auth.js' import { AccountManager } from '../plugin/accounts.js' import * as logger from '../plugin/logger.js' +import { reasoningCorrelationCache } from '../plugin/reasoning/correlation-cache.js' import type { ManagedAccount, SdkPreparedRequest } from '../plugin/types.js' // RequestHandler is pure orchestration: handle() routes by KIRO_API_PATTERN @@ -64,6 +66,7 @@ const baseConfig = { stream_event_timeout_enabled: false, stream_buffer_until_complete: false, stream_max_attempts: 3, + stream_recovery_mode: 'off', sdk_response_timeout_enabled: false, sdk_response_timeout_ms: 300000, sdk_http_keep_alive: false, @@ -88,7 +91,10 @@ interface Fakes { forceRefresh: ReturnType } errorHandler: { handle: ReturnType; handleNetworkError: ReturnType } - responseHandler: { handleSdkSuccess: ReturnType } + responseHandler: { + handleSdkSuccess: ReturnType + prepareSdkStreamingAttempt?: ReturnType + } usageTracker: { syncUsage: ReturnType } sdkSend: ReturnType accountManager: any @@ -108,6 +114,8 @@ function buildHandler(opts: { streamEventTimeoutEnabled?: boolean streamBufferUntilComplete?: boolean streamMaxAttempts?: number + streamRecoveryMode?: 'off' | 'reasoning_restart' + maxRequestIterations?: number sdkResponseTimeoutEnabled?: boolean sdkResponseTimeoutMs?: number }): { handler: RequestHandler; fakes: Fakes } { @@ -148,8 +156,14 @@ function buildHandler(opts: { handle: mock(async () => errorQueue.shift() ?? { shouldRetry: false }), handleNetworkError: mock(async () => ({ shouldRetry: false })) } + const realResponseHandler = new ResponseHandler() const responseHandler = opts.useRealResponseHandler - ? { handleSdkSuccess: mock(new ResponseHandler().handleSdkSuccess.bind(new ResponseHandler())) } + ? { + handleSdkSuccess: mock(realResponseHandler.handleSdkSuccess.bind(realResponseHandler)), + prepareSdkStreamingAttempt: mock( + realResponseHandler.prepareSdkStreamingAttempt.bind(realResponseHandler) + ) + } : { handleSdkSuccess: mock( async ( @@ -176,12 +190,14 @@ function buildHandler(opts: { accountManager, { ...baseConfig, + max_request_iterations: opts.maxRequestIterations ?? baseConfig.max_request_iterations, request_timeout_ms: opts.requestTimeoutMs ?? baseConfig.request_timeout_ms, stream_event_timeout_enabled: opts.streamEventTimeoutEnabled ?? baseConfig.stream_event_timeout_enabled, stream_buffer_until_complete: opts.streamBufferUntilComplete ?? baseConfig.stream_buffer_until_complete, stream_max_attempts: opts.streamMaxAttempts ?? baseConfig.stream_max_attempts, + stream_recovery_mode: opts.streamRecoveryMode ?? 'off', sdk_response_timeout_enabled: opts.sdkResponseTimeoutEnabled ?? baseConfig.sdk_response_timeout_enabled, sdk_response_timeout_ms: opts.sdkResponseTimeoutMs ?? baseConfig.sdk_response_timeout_ms @@ -221,6 +237,14 @@ function sdkStream(events: unknown[], error?: Error): object { } } +function streamedText(body: string): string { + return body + .split('\n\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice('data: '.length)).choices?.[0]?.delta?.content ?? '') + .join('') +} + function installImmediateStreamBackoff(handler: RequestHandler): void { const internals = handler as unknown as { streamRetryRandom: () => number @@ -1016,6 +1040,218 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { } }) + test('reasoning-only live failure restarts inside one SSE when recovery is enabled', async () => { + const acc = makeAccount({ id: 'A', failCount: 2, unhealthyReason: 'transient' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('reasoning transport reset') + ), + sdkStream([ + { reasoningContentEvent: { text: 'complete reasoning' } }, + { assistantResponseEvent: { content: 'complete answer' } }, + { + metadataEvent: { + tokenUsage: { uncachedInputTokens: 3, outputTokens: 2, totalTokens: 5 } + } + } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(fakes.accountSelector.selectAlternativeAccount).toHaveBeenCalledTimes(0) + expect(body).toContain('partial reasoning') + expect(body).toContain('complete reasoning') + expect(body).toContain('complete answer') + expect(body.split('"finish_reason":"stop"')).toHaveLength(2) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect(acc.failCount).toBe(0) + }) + + test('the first live recovery reuses its account and a later recovery prefers an alternative', async () => { + const a = makeAccount({ id: 'A' }) + const b = makeAccount({ id: 'B' }) + const { handler, fakes } = buildHandler({ + accounts: [a, b], + selectResults: [a], + alternativeAccount: b, + sdkResults: [ + sdkStream([{ reasoningContentEvent: { text: 'attempt one' } }], new Error('reset one')), + sdkStream([{ reasoningContentEvent: { text: 'attempt two' } }], new Error('reset two')), + sdkStream([ + { assistantResponseEvent: { content: 'from alternative' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.accountSelector.selectAlternativeAccount).toHaveBeenCalledTimes(1) + expect( + fakes.accountManager.toAuthDetails.mock.calls.map((call: [ManagedAccount]) => call[0].id) + ).toEqual(['A', 'A', 'A', 'B', 'B']) + }) + + test('visible text makes a live failure ineligible for reasoning restart', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ assistantResponseEvent: { content: 'visible before failure' } }], + new Error('late text failure') + ), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const stream = response.body + if (!stream) throw new Error('expected a streaming response body') + const reader = stream.getReader() + let delivered = '' + const draining = (async () => { + while (true) { + const item = await reader.read() + if (item.done) return + delivered += new TextDecoder().decode(item.value) + } + })() + + await expect(draining).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(delivered).toContain('visible befo') + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + }) + + test('raw tool intent makes a reasoning-only live failure ineligible for restart', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'reasoning before tool intent' } }, + { toolUseEvent: { name: 'read_file', toolUseId: 'tool-1', input: '{"path":"/a' } } + ], + new Error('tool stream failed') + ), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + }) + + test('clean EOF after reasoning is a recoverable semantic truncation in recovery mode', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([{ reasoningContentEvent: { text: 'truncated reasoning' } }]), + sdkStream([ + { reasoningContentEvent: { text: 'replacement reasoning' } }, + { assistantResponseEvent: { content: 'replacement answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 2, totalTokens: 2 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(body).toContain('truncated reasoning') + expect(body).toContain('replacement answer') + expect(body.split('"finish_reason":"stop"')).toHaveLength(2) + }) + + test('reasoning restart honors stream_max_attempts across initial and recovery sends', async () => { + const acc = makeAccount({ id: 'A' }) + const failure = new Error('persistent reasoning failure') + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([{ reasoningContentEvent: { text: 'attempt one' } }], failure), + sdkStream([{ reasoningContentEvent: { text: 'attempt two' } }], failure), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart', + streamMaxAttempts: 2 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + }) + + test('reasoning restart consumes RetryStrategy budget before every recovery send', async () => { + const acc = makeAccount({ id: 'A' }) + const failure = new Error('persistent reasoning failure') + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([{ reasoningContentEvent: { text: 'attempt one' } }], failure), + sdkStream([{ reasoningContentEvent: { text: 'attempt two' } }], failure), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart', + streamMaxAttempts: 3, + maxRequestIterations: 2 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + }) + test('stream retry jitter stays inside the documented bounds', () => { const { handler } = buildHandler({}) const internals = handler as unknown as { @@ -1243,6 +1479,233 @@ describe('RequestHandler.handle — clean end without completion metadata', () = }) }) +describe('RequestHandler.handle — reasoning signature safety gates', () => { + beforeEach(() => { + reasoningCorrelationCache.clearAllForTests() + }) + + const signedToolEvents = (label: string): unknown[] => [ + { reasoningContentEvent: { text: `reasoning-${label}` } }, + { reasoningContentEvent: { signature: `signature-${label}` } }, + { assistantResponseEvent: { content: `visible-${label}` } }, + { + toolUseEvent: { + name: 'read_file', + toolUseId: `tool-${label}`, + input: `{"path":"/${label}"}` + } + }, + { + toolUseEvent: { + name: 'read_file', + toolUseId: `tool-${label}`, + input: '', + stop: true + } + }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ] + + const lookupSignedTool = (label: string) => + reasoningCorrelationCache.lookup({ + reasoningText: `reasoning-${label}`, + visibleText: `visible-${label}`, + toolUses: [ + { + toolUseId: `tool-${label}`, + name: 'read_file', + argumentsJson: `{"path":"/${label}"}` + } + ], + effectiveModel: 'claude-sonnet-4-5' + }) + + test('two healthy concurrent requests on one account both publish their envelopes', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler } = buildHandler({ + selectResults: [acc, acc], + sdkResults: [sdkStream(signedToolEvents('first')), sdkStream(signedToolEvents('second'))], + streaming: true, + useRealResponseHandler: true + }) + + const first = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const second = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await Promise.all([first.text(), second.text()]) + + expect(lookupSignedTool('first').envelope).toEqual({ + kind: 'reasoningText', + text: 'reasoning-first', + signature: 'signature-first' + }) + expect(lookupSignedTool('second').envelope).toEqual({ + kind: 'reasoningText', + text: 'reasoning-second', + signature: 'signature-second' + }) + }) + + test('a recovered tool completion does not publish its final-attempt envelope', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('late reset') + ), + sdkStream(signedToolEvents('recovered')) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(lookupSignedTool('recovered').refusal).toBe('miss') + }) + + test('a superseded attempt cannot republish after the recovered final answer clears its loop', async () => { + const acc = makeAccount({ id: 'A' }) + const loopId = 'loop-superseded-attempt' + reasoningCorrelationCache.publish({ + envelope: { kind: 'reasoningText', text: 'seed reasoning', signature: 'seed signature' }, + reasoningText: 'seed reasoning', + visibleText: 'seed visible', + toolUses: [ + { + toolUseId: loopId, + name: 'seed_tool', + argumentsJson: '{}' + } + ], + effectiveModel: 'claude-sonnet-4-5', + loopId, + accountId: acc.id, + attemptId: 'seed-attempt' + }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [{}, {}], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + let prepareCalls = 0 + let supersededLifecycle: SdkResponseLifecycle | undefined + const partialChunk = { + choices: [{ index: 0, delta: { reasoning_content: 'partial' }, finish_reason: null }] + } + const terminalChunk = { + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] + } + const prepareSdkStreamingAttempt = mock( + async (input: { readonly lifecycle: SdkResponseLifecycle }) => { + prepareCalls++ + if (prepareCalls === 1) { + supersededLifecycle = input.lifecycle + let reads = 0 + return { + chunks: { + async next(): Promise> { + reads++ + if (reads === 1) return { done: false, value: partialChunk } + throw new SdkEventStreamIterationError(new Error('superseded stream failed')) + } + }, + observed: () => ({ + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: false + }), + close: async () => {}, + complete: async () => {} + } + } + + let reads = 0 + return { + chunks: { + async next(): Promise> { + reads++ + return reads === 1 + ? { done: false, value: terminalChunk } + : { done: true, value: undefined } + } + }, + observed: () => ({ + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: false + }), + close: async () => {}, + complete: async (completion: { readonly recovered: boolean }) => { + await input.lifecycle.onComplete?.({ + reasoningText: 'final reasoning', + visibleText: 'final answer', + toolUses: [], + attemptId: input.lifecycle.attemptId ?? '', + loopId, + effectiveModel: 'claude-sonnet-4-5', + recovered: completion.recovered + }) + } + } + } + ) + Object.assign(handler, { + responseHandler: { + handleSdkSuccess: fakes.responseHandler.handleSdkSuccess, + prepareSdkStreamingAttempt + } + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + const staleLifecycle = supersededLifecycle + if (!staleLifecycle) throw new Error('expected the superseded lifecycle to be captured') + await staleLifecycle.onComplete?.({ + envelope: { + kind: 'reasoningText', + text: 'superseded reasoning', + signature: 'superseded signature' + }, + reasoningText: 'superseded reasoning', + visibleText: 'superseded visible', + toolUses: [ + { + toolUseId: loopId, + name: 'stale_tool', + argumentsJson: '{}' + } + ], + attemptId: staleLifecycle.attemptId ?? '', + loopId, + effectiveModel: 'claude-sonnet-4-5', + recovered: false + }) + + expect(reasoningCorrelationCache.sizeForLoop(loopId)).toBe(0) + expect( + reasoningCorrelationCache.lookup({ + reasoningText: 'superseded reasoning', + visibleText: 'superseded visible', + toolUses: [ + { + toolUseId: loopId, + name: 'stale_tool', + argumentsJson: '{}' + } + ], + effectiveModel: 'claude-sonnet-4-5' + }).refusal + ).toBe('miss') + }) +}) + describe('RequestHandler.handle — cancellation and queue release', () => { test('inbound abort interrupts a pending send without retry and releases the next request', async () => { const acc = makeAccount({ id: 'A' }) @@ -1447,6 +1910,121 @@ describe('RequestHandler.handle — cancellation and queue release', () => { expect(fakes.sdkSend).toHaveBeenCalledTimes(1) }) + test('inbound abort during reasoning recovery backoff releases the request without another send', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc, acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('late reset') + ), + sdkStream([ + { assistantResponseEvent: { content: 'next request succeeds' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + const internals = handler as unknown as { + sleep: (ms: number, signal?: AbortSignal) => Promise + } + let notifyBackoffStarted: (() => void) | undefined + const backoffStarted = new Promise((resolve) => { + notifyBackoffStarted = resolve + }) + internals.sleep = async (_ms, signal) => { + notifyBackoffStarted?.() + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + } + const controller = new AbortController() + const response = await handler.handle( + KIRO_URL, + { body: JSON.stringify({}), signal: controller.signal }, + noToast + ) + const reading = response.text() + + await backoffStarted + controller.abort(new DOMException('cancelled during recovery backoff', 'AbortError')) + + await expect(reading).rejects.toMatchObject({ name: 'AbortError' }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + const next = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + expect(streamedText(await next.text())).toBe('next request succeeds') + }) + + test('inbound abort interrupts the second recovery send and allows a later request', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler } = buildHandler({ + selectResults: [acc, acc], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + const internals = handler as unknown as { + makeSdkClient: () => { + send: (command: unknown, options: { abortSignal: AbortSignal }) => Promise + } + sleep: (ms: number, signal?: AbortSignal) => Promise + } + let sendCalls = 0 + let notifySecondSendStarted: (() => void) | undefined + const secondSendStarted = new Promise((resolve) => { + notifySecondSendStarted = resolve + }) + internals.sleep = async (_ms, signal) => { + if (signal?.aborted) throw signal.reason + } + internals.makeSdkClient = () => ({ + send: async (_command, options) => { + sendCalls++ + if (sendCalls === 1) { + return sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('late reset') + ) + } + if (sendCalls === 2) { + notifySecondSendStarted?.() + return new Promise((_resolve, reject) => { + options.abortSignal.addEventListener( + 'abort', + () => reject(options.abortSignal.reason), + { + once: true + } + ) + }) + } + return sdkStream([ + { assistantResponseEvent: { content: 'later request succeeds' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + } + }) + const controller = new AbortController() + const response = await handler.handle( + KIRO_URL, + { body: JSON.stringify({}), signal: controller.signal }, + noToast + ) + const reading = response.text() + + await secondSendStarted + controller.abort(new DOMException('cancelled during recovery send', 'AbortError')) + + await expect(reading).rejects.toMatchObject({ name: 'AbortError' }) + expect(sendCalls).toBe(2) + const next = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + expect(streamedText(await next.text())).toBe('later request succeeds') + expect(sendCalls).toBe(3) + }) + test('periodic upstream activity allows a thinking stream to outlive the timeout window', async () => { const acc = makeAccount({ id: 'A' }) const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/src/core/request/recovery-attempt.ts b/src/core/request/recovery-attempt.ts new file mode 100644 index 0000000..053a834 --- /dev/null +++ b/src/core/request/recovery-attempt.ts @@ -0,0 +1,301 @@ +import { + GenerateAssistantResponseCommand, + type GenerateAssistantResponseCommandOutput +} from '@aws/codewhisperer-streaming-client' +import type { KiroConfig } from '../../plugin/config' +import * as logger from '../../plugin/logger' +import { EmittedOutputAccumulator } from '../../plugin/reasoning/emitted-output' +import { StreamObserver } from '../../plugin/streaming/stream-observer' +import type { KiroAuthDetails, ManagedAccount, SdkPreparedRequest } from '../../plugin/types' +import type { + ResponseHandler, + SdkCompletionPayload, + SdkResponseLifecycle, + SdkStreamingAttempt +} from './response-handler' +import { SdkEventStreamIterationError } from './stream-error' +import { STREAM_MISSING_COMPLETION_LOG } from './stream-log-events' + +type RecoveryConfig = Pick< + KiroConfig, + | 'enable_log_api_request' + | 'request_timeout_ms' + | 'sdk_http_keep_alive' + | 'sdk_response_timeout_enabled' + | 'sdk_response_timeout_ms' + | 'stream_event_timeout_enabled' + | 'stream_max_attempts' + | 'stream_recovery_mode' +> + +export type RecoveryAttemptSeed = { + readonly account: ManagedAccount + readonly auth: KiroAuthDetails + readonly prepared: SdkPreparedRequest + readonly observer: StreamObserver + readonly emitted: EmittedOutputAccumulator + readonly eventCount: number + readonly startedAt: number + readonly apiTimestamp: string | null +} + +export type RecoveryRequestContext = { + readonly body: unknown + readonly model: string + readonly think: boolean + readonly budget: number + readonly disableReasoningReplay: boolean + readonly inheritedLoopId: string | undefined + readonly signal: AbortSignal + readonly priorStreamFailures: number +} + +export type RecoveryAttemptServices = { + readonly consumeRequestIteration: () => void + readonly toAuthDetails: (account: ManagedAccount) => KiroAuthDetails + readonly refreshAccount: ( + account: ManagedAccount, + auth: KiroAuthDetails + ) => Promise<{ readonly account: ManagedAccount; readonly shouldContinue: boolean }> + readonly wait: (milliseconds: number, signal: AbortSignal) => Promise + readonly prepareRequest: (account: ManagedAccount, auth: KiroAuthDetails) => SdkPreparedRequest + readonly makeSdkClient: ( + auth: KiroAuthDetails, + prepared: SdkPreparedRequest + ) => { + readonly send: ( + command: GenerateAssistantResponseCommand, + options: { readonly abortSignal: AbortSignal } + ) => Promise + } + readonly responseHandler: ResponseHandler + readonly beginUpstreamWait: ( + phase: 'SDK response' | 'stream event', + timeoutMs: number, + details: Record + ) => void + readonly endUpstreamWait: () => void + readonly nextAccountAttemptEpoch: (accountId: string) => number + readonly isAccountAttemptCurrent: (accountId: string, epoch: number) => boolean + readonly setCurrentAttemptId: (attemptId: string) => void + readonly getCurrentAttemptId: () => string + readonly markSuccessful: (account: ManagedAccount) => void + readonly syncUsage: ( + account: ManagedAccount, + auth: KiroAuthDetails, + isCurrent: () => boolean + ) => Promise + readonly commitReasoning: ( + completed: SdkCompletionPayload | undefined, + accountId: string, + owningAttemptId: string, + latestAttemptId: string + ) => void + readonly logSdkRequest: ( + prepared: SdkPreparedRequest, + account: ManagedAccount, + timestamp: string + ) => void + readonly logSdkResponse: (prepared: SdkPreparedRequest, timestamp: string) => void + readonly markSendResolved: () => void + readonly describeError: (error: unknown) => unknown +} + +export type RecoveryAttemptResult = { + readonly account: ManagedAccount + readonly handle: SdkStreamingAttempt + readonly logDetails: (details?: Record) => Record +} + +export type RecoveryAttemptFactoryOptions = { + readonly config: RecoveryConfig + readonly request: RecoveryRequestContext + readonly initial: RecoveryAttemptSeed + readonly services: RecoveryAttemptServices +} + +export class RecoveryAttemptFactory { + private readonly config: RecoveryConfig + private readonly request: RecoveryRequestContext + private readonly initial: RecoveryAttemptSeed + private readonly services: RecoveryAttemptServices + + constructor(options: RecoveryAttemptFactoryOptions) { + this.config = options.config + this.request = options.request + this.initial = options.initial + this.services = options.services + } + + async open( + attemptIndex: number, + selectedAccount: ManagedAccount + ): Promise { + const state = await this.resolveAttemptState(attemptIndex, selectedAccount) + let eventCount = state.eventCount + const absoluteAttempt = this.request.priorStreamFailures + attemptIndex + const logDetails = (details: Record = {}): Record => ({ + conversationId: state.prepared.conversationId, + model: this.request.model, + effectiveModel: state.prepared.effectiveModel, + region: state.prepared.region, + account: state.account.email, + accountId: state.account.id, + streamAttempt: absoluteAttempt, + maxStreamAttempts: this.config.stream_max_attempts, + streamDeliveryMode: 'live', + sdkHttpKeepAlive: this.config.sdk_http_keep_alive, + processId: process.pid, + bunVersion: process.versions.bun, + upstreamEventCount: eventCount, + streamElapsedMs: Date.now() - state.startedAt, + emittedReasoningChars: state.emitted.reasoningText.length, + emittedVisibleChars: state.emitted.visibleText.length, + emittedToolCount: state.emitted.toolUses().length, + sawToolIntent: state.observer.sawToolIntent, + ...details + }) + + if (state.apiTimestamp && attemptIndex > 1) { + this.services.logSdkRequest(state.prepared, state.account, state.apiTimestamp) + } + + const epoch = this.services.nextAccountAttemptEpoch(state.account.id) + const isCurrent = (): boolean => this.services.isAccountAttemptCurrent(state.account.id, epoch) + const attemptId = crypto.randomUUID() + this.services.setCurrentAttemptId(attemptId) + let completionDone = false + const onComplete = async (completed?: SdkCompletionPayload): Promise => { + if (!completionDone) { + completionDone = true + if (isCurrent()) { + this.services.markSuccessful(state.account) + await this.services.syncUsage(state.account, state.auth, isCurrent) + } + } + this.services.commitReasoning( + completed, + state.account.id, + attemptId, + this.services.getCurrentAttemptId() + ) + } + const lifecycle: SdkResponseLifecycle = { + signal: this.request.signal, + onUpstreamWaitStart: ({ eventIndex }) => { + eventCount = eventIndex + if (eventIndex === 0) { + if (!this.config.sdk_response_timeout_enabled) this.services.endUpstreamWait() + return + } + if (!this.config.stream_event_timeout_enabled) return + this.services.beginUpstreamWait('stream event', this.config.request_timeout_ms, { + conversationId: state.prepared.conversationId, + model: this.request.model, + effectiveModel: state.prepared.effectiveModel, + region: state.prepared.region, + eventIndex + }) + }, + onUpstreamWaitEnd: this.services.endUpstreamWait, + onIterationError: (error, afterCompletionMetadata) => { + if (!afterCompletionMetadata) return + logger.log( + 'Kiro SDK event stream closed after completion metadata', + logDetails({ + outcome: 'ignored_after_completion_metadata', + platform: process.platform, + afterCompletionMetadata, + error: this.services.describeError(error) + }) + ) + }, + onCleanEofWithoutCompletionMetadata: () => { + logger.warn( + STREAM_MISSING_COMPLETION_LOG, + logDetails({ outcome: 'clean_eof_without_completion_metadata' }) + ) + }, + onComplete, + streamObserver: state.observer, + emittedOutput: state.emitted, + attemptId, + ...(this.request.inheritedLoopId !== undefined + ? { inheritedLoopId: this.request.inheritedLoopId } + : {}), + effectiveModel: state.prepared.effectiveModel, + recoveryMode: this.config.stream_recovery_mode + } + + const client = this.services.makeSdkClient(state.auth, state.prepared) + const command = new GenerateAssistantResponseCommand({ + conversationState: state.prepared.conversationState as never, + profileArn: state.prepared.profileArn + }) + this.beginSdkResponseWait(state.prepared) + + let sdkResponse: GenerateAssistantResponseCommandOutput + try { + sdkResponse = await client.send(command, { abortSignal: this.request.signal }) + } catch (error) { + this.services.endUpstreamWait() + throw error + } + this.services.markSendResolved() + if (state.apiTimestamp) this.services.logSdkResponse(state.prepared, state.apiTimestamp) + + const handle = await this.services.responseHandler.prepareSdkStreamingAttempt({ + sdkResponse, + model: this.request.model, + conversationId: state.prepared.conversationId, + lifecycle, + recoveryMode: this.config.stream_recovery_mode + }) + return { account: state.account, handle, logDetails } + } + + private async resolveAttemptState( + attemptIndex: number, + selectedAccount: ManagedAccount + ): Promise { + if (attemptIndex === 1) return this.initial + this.services.consumeRequestIteration() + + let account = selectedAccount + let auth = this.services.toAuthDetails(account) + const refreshed = await this.services.refreshAccount(account, auth) + account = refreshed.account + if (refreshed.shouldContinue) { + await this.services.wait(500, this.request.signal) + throw new SdkEventStreamIterationError( + new Error('Kiro token refresh requested another recovery iteration') + ) + } + auth = this.services.toAuthDetails(account) + return { + account, + auth, + prepared: this.services.prepareRequest(account, auth), + observer: new StreamObserver(), + emitted: new EmittedOutputAccumulator(), + eventCount: 0, + startedAt: Date.now(), + apiTimestamp: this.config.enable_log_api_request ? logger.getTimestamp() : null + } + } + + private beginSdkResponseWait(prepared: SdkPreparedRequest): void { + if (!this.config.sdk_response_timeout_enabled) return + const messageContext = + prepared.conversationState.currentMessage?.userInputMessage?.userInputMessageContext + this.services.beginUpstreamWait('SDK response', this.config.sdk_response_timeout_ms, { + conversationId: prepared.conversationId, + model: this.request.model, + effectiveModel: prepared.effectiveModel, + effort: prepared.effort, + region: prepared.region, + historyLength: prepared.conversationState.history?.length ?? 0, + toolCount: messageContext?.tools?.length ?? 0 + }) + } +} diff --git a/src/core/request/recovery-integration.ts b/src/core/request/recovery-integration.ts new file mode 100644 index 0000000..83e3d57 --- /dev/null +++ b/src/core/request/recovery-integration.ts @@ -0,0 +1,100 @@ +import * as logger from '../../plugin/logger' +import type { ManagedAccount } from '../../plugin/types' +import type { RecoveryAttemptFactory, RecoveryAttemptResult } from './recovery-attempt' +import { encodeSseChunk, type SdkStreamingAttempt } from './response-handler' +import { UpstreamUnexpectedError } from './stream-error' +import { StreamRecoveryCoordinator, type StreamRecoveryMode } from './stream-recovery' + +export type LiveRecoveryOptions = { + readonly mode: StreamRecoveryMode + readonly maxAttempts: number + readonly priorStreamFailures: number + readonly signal: AbortSignal + readonly initialAccount: ManagedAccount + readonly attemptFactory: RecoveryAttemptFactory + readonly retryDelay: (failureCount: number) => number + readonly wait: (milliseconds: number, signal: AbortSignal) => Promise + readonly selectAlternativeAccount: (excludedAccountId: string) => Promise + readonly describeError: (error: unknown) => unknown + readonly onTerminal: () => void + readonly onCancel: (reason: unknown) => void +} + +export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): Promise { + let activeAccount = options.initialAccount + let nextAccount = options.initialAccount + let completedAttempt: SdkStreamingAttempt | undefined + let activeLogDetails = (_details: Record = {}): Record => ({}) + + const openAttempt = async (attemptIndex: number): Promise => { + const result: RecoveryAttemptResult = await options.attemptFactory.open( + attemptIndex, + nextAccount + ) + activeAccount = result.account + completedAttempt = result.handle + activeLogDetails = result.logDetails + return result.handle + } + + const initialAttempt = await openAttempt(1) + const coordinator = new StreamRecoveryCoordinator({ + mode: options.mode, + maxAttempts: options.maxAttempts, + signal: options.signal, + initialAttempt, + attemptFactory: openAttempt, + delayFn: async (failedAttemptIndex, recoverySignal) => { + const failureCount = options.priorStreamFailures + failedAttemptIndex + const delayMs = options.retryDelay(failureCount) + await options.wait(delayMs, recoverySignal) + nextAccount = + failureCount === 1 + ? activeAccount + : ((await options.selectAlternativeAccount(activeAccount.id)) ?? activeAccount) + logger.warn( + 'Kiro SDK event stream iteration failed', + activeLogDetails({ + outcome: 'retrying', + platform: process.platform, + nextAttempt: failureCount + 1, + delayMs, + nextAccount: nextAccount.email + }) + ) + }, + mapError: (error) => { + logger.error( + 'Kiro SDK event stream iteration failed', + activeLogDetails({ + outcome: 'terminated_after_output', + platform: process.platform, + emittedOutput: true, + error: options.describeError(error) + }) + ) + return new UpstreamUnexpectedError(error, true) + }, + encodeChunk: encodeSseChunk, + onComplete: async (completion) => { + const attempt = completedAttempt + if (!attempt) throw new Error('No completed Kiro recovery attempt is available') + await attempt.complete(completion) + if (options.priorStreamFailures > 0 || completion.recovered) { + logger.log( + 'Kiro SDK event stream retry recovered', + activeLogDetails({ + outcome: 'recovered', + attempts: options.priorStreamFailures + completion.attemptIndex + }) + ) + } + }, + onTerminal: options.onTerminal, + onCancel: options.onCancel + }) + + return new Response(coordinator.stream, { + headers: { 'Content-Type': 'text/event-stream' } + }) +} diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index b4ace56..284dc74 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -20,10 +20,15 @@ import { AccountSelector } from '../account/account-selector' import { UsageTracker } from '../account/usage-tracker' import { TokenRefresher } from '../auth/token-refresher' import { ErrorHandler, isKiroContextOverflowBody, type RequestContext } from './error-handler' +import { RecoveryAttemptFactory } from './recovery-attempt' +import { createLiveRecoveryResponse } from './recovery-integration' import { ResponseHandler, type SdkCompletionPayload } from './response-handler' import { RetryStrategy } from './retry-strategy' import { buildSdkRequestLogPayload } from './sdk-log-payload' import { SdkEventStreamIterationError, UpstreamUnexpectedError } from './stream-error' +import { STREAM_MISSING_COMPLETION_LOG, STREAM_REQUEST_STARTED_LOG } from './stream-log-events' + +export { STREAM_MISSING_COMPLETION_LOG, STREAM_REQUEST_STARTED_LOG } from './stream-log-events' type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void @@ -31,17 +36,6 @@ const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/ const REAUTH_FAILURE_COOLDOWN_MS = 60000 type UpstreamWaitPhase = 'SDK response' | 'stream event' -/** - * Written once per inbound streaming request, unconditionally — it is the - * denominator every stream-failure rate is measured against, so it must not - * depend on `enable_log_api_request`. Log-analysis scripts match this exact - * string; changing it invalidates every window collected before the change. - */ -export const STREAM_REQUEST_STARTED_LOG = 'Kiro stream request started' - -/** Emitted on a clean SDK `done` that never carried completion metadata. */ -export const STREAM_MISSING_COMPLETION_LOG = 'Kiro stream ended without completion metadata' - function describeError(error: unknown, depth = 0): unknown { if (!(error instanceof Error)) return String(error) const code = (error as Error & { code?: unknown }).code @@ -314,6 +308,112 @@ export class RequestHandler { } let sendResolved = false try { + const liveRecoveryEnabled = + sdkPrep.streaming && + !this.config.stream_buffer_until_complete && + this.config.stream_recovery_mode === 'reasoning_restart' + if (liveRecoveryEnabled) { + const priorStreamFailures = streamFailureCount + const availableStreamAttempts = this.config.stream_max_attempts - priorStreamFailures + const availableRequestIterations = + this.config.max_request_iterations - retryContext.iterations + 1 + const maxAttempts = Math.max( + 1, + Math.min(availableStreamAttempts, availableRequestIterations) + ) + const attemptFactory = new RecoveryAttemptFactory({ + config: this.config, + request: { + body: init?.body, + model, + think, + budget, + disableReasoningReplay: handlerContext.disableReasoningReplay === true, + inheritedLoopId, + signal, + priorStreamFailures + }, + initial: { + account: acc, + auth, + prepared: sdkPrep, + observer: streamObserver, + emitted: emittedOutput, + eventCount: upstreamEventCount, + startedAt: streamAttemptStartedAt, + apiTimestamp + }, + services: { + consumeRequestIteration: () => { + const check = this.retryStrategy.shouldContinue(retryContext) + if (!check.canContinue) throw new Error(check.error) + }, + toAuthDetails: (account) => this.accountManager.toAuthDetails(account), + refreshAccount: (account, accountAuth) => + this.tokenRefresher.refreshIfNeeded(account, accountAuth, showToast), + wait: (milliseconds, waitSignal) => this.sleep(milliseconds, waitSignal), + prepareRequest: (_account, accountAuth) => + this.prepareSdkRequest( + init?.body, + model, + accountAuth, + think, + budget, + showToast, + handlerContext.disableReasoningReplay === true + ), + makeSdkClient: (accountAuth, prepared) => + this.makeSdkClient(accountAuth, prepared.region, prepared.effort), + responseHandler: this.responseHandler, + beginUpstreamWait, + endUpstreamWait, + nextAccountAttemptEpoch: (accountId) => this.nextAccountAttemptEpoch(accountId), + isAccountAttemptCurrent: (accountId, epoch) => + this.accountAttemptEpochs.get(accountId) === epoch, + setCurrentAttemptId: (attemptId) => { + currentAttemptId = attemptId + }, + getCurrentAttemptId: () => currentAttemptId, + markSuccessful: (account) => this.handleSuccessfulRequest(account), + syncUsage: (account, accountAuth, isCurrent) => + this.usageTracker.syncUsage(account, accountAuth, isCurrent), + commitReasoning: (completed, accountId, owningAttemptId, latestAttemptId) => + this.commitReasoningCorrelation( + completed, + accountId, + owningAttemptId, + latestAttemptId + ), + logSdkRequest: (prepared, account, timestamp) => + this.logSdkRequest(prepared, account, timestamp), + logSdkResponse: (prepared, timestamp) => this.logSdkResponse(prepared, timestamp), + markSendResolved: () => { + sendResolved = true + }, + describeError + } + }) + const response = await createLiveRecoveryResponse({ + mode: this.config.stream_recovery_mode, + maxAttempts, + priorStreamFailures, + signal, + initialAccount: acc, + attemptFactory, + retryDelay: (failureCount) => this.getStreamRetryDelay(failureCount), + wait: (milliseconds, waitSignal) => this.sleep(milliseconds, waitSignal), + selectAlternativeAccount: (accountId) => + this.accountSelector.selectAlternativeAccount(new Set([accountId])), + describeError, + onTerminal: cleanupRequest, + onCancel: (reason) => requestController.abort(reason) + }) + + responseOwnsLifecycle = true + sendResolved = true + return response + } + const client = this.makeSdkClient(auth, sdkPrep.region, sdkPrep.effort) const command = new GenerateAssistantResponseCommand({ conversationState: sdkPrep.conversationState as any, @@ -338,7 +438,7 @@ export class RequestHandler { const accountId = acc.id const onStreamComplete = async (completed?: SdkCompletionPayload): Promise => { await completeRequest() - this.commitReasoningCorrelation(completed, accountId, currentAttemptId) + this.commitReasoningCorrelation(completed, accountId, attemptId, currentAttemptId) } let sdkResponse: GenerateAssistantResponseCommandOutput @@ -414,6 +514,7 @@ export class RequestHandler { attemptId, ...(inheritedLoopId !== undefined ? { inheritedLoopId } : {}), effectiveModel: sdkPrep.effectiveModel, + recoveryMode: this.config.stream_recovery_mode, onTerminal: cleanupRequest, onCancel: (reason) => requestController.abort(reason), bufferUntilComplete: this.config.stream_buffer_until_complete, @@ -735,10 +836,12 @@ export class RequestHandler { private commitReasoningCorrelation( completed: SdkCompletionPayload | undefined, accountId: string, - owningAttemptId: string + owningAttemptId: string, + latestAttemptId: string ): void { if (!completed || completed.loopId === undefined) return if (completed.attemptId === '' || completed.attemptId !== owningAttemptId) return + if (owningAttemptId !== latestAttemptId) return // A final answer ends the loop, so its entries are torn down. A tool-emitting // turn that produced no signed envelope merely skips publication — the loop @@ -747,6 +850,7 @@ export class RequestHandler { reasoningCorrelationCache.clearLoop(completed.loopId) return } + if (completed.recovered) return if (!completed.envelope) return reasoningCorrelationCache.publish({ diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index 399c19e..97efe09 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -10,6 +10,11 @@ import { transformSdkStream } from '../../plugin/streaming/sdk-stream-transforme import type { StreamObserver } from '../../plugin/streaming/stream-observer.js' import type { KiroReasoningContent } from '../../plugin/types.js' import { SdkEventStreamIterationError } from './stream-error.js' +import type { + AttemptHandle, + StreamRecoveryCompletion, + StreamRecoveryMode +} from './stream-recovery.js' /** * What a completed SDK stream hands back to the request layer. @@ -27,6 +32,19 @@ export interface SdkCompletionPayload { attemptId: string loopId?: string effectiveModel: string + recovered?: boolean +} + +export type SdkStreamingAttempt = AttemptHandle & { + readonly complete: (completion: StreamRecoveryCompletion) => Promise +} + +export type SdkStreamingAttemptInput = { + readonly sdkResponse: unknown + readonly model: string + readonly conversationId: string + readonly lifecycle: SdkResponseLifecycle + readonly recoveryMode: StreamRecoveryMode } export interface SdkResponseLifecycle { @@ -60,6 +78,7 @@ export interface SdkResponseLifecycle { * metadata. Observation only — success handling proceeds exactly as before. */ onCleanEofWithoutCompletionMetadata?: () => void + recoveryMode?: StreamRecoveryMode } interface WrappedSdkStream { @@ -193,10 +212,33 @@ function isSemanticChunk(chunk: any): boolean { ) } -function encodeSseChunk(chunk: unknown): Uint8Array { +export function encodeSseChunk(chunk: unknown): Uint8Array { return new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`) } +class SemanticStreamTruncationError extends Error { + readonly name = 'SemanticStreamTruncationError' + + constructor() { + super('Kiro SDK event stream ended without completion metadata after semantic output') + } +} + +function isSemanticTruncation( + mode: StreamRecoveryMode, + wrapped: WrappedSdkStream, + emitted: EmittedOutputAccumulator, + observer: StreamObserver | undefined +): boolean { + return ( + mode !== 'off' && + !wrapped.completionMetadataSeen() && + (emitted.reasoningText.length > 0 || emitted.visibleText.length > 0) && + emitted.toolUses().length === 0 && + observer?.sawToolIntent !== true + ) +} + function bufferedSseResponse(chunks: Uint8Array[]): Response { let index = 0 return new Response( @@ -219,7 +261,8 @@ export class ResponseHandler { lifecycle: SdkResponseLifecycle, reasoning: ReasoningAccumulator, emitted: EmittedOutputAccumulator, - model: string + model: string, + recovered: boolean ): Promise { if (!lifecycle.onComplete) return const toolUses = emitted.toolUses() @@ -232,10 +275,95 @@ export class ResponseHandler { toolUses, attemptId: lifecycle.attemptId ?? '', ...(resolved.loopId !== undefined ? { loopId: resolved.loopId } : {}), - effectiveModel: lifecycle.effectiveModel ?? model + effectiveModel: lifecycle.effectiveModel ?? model, + recovered }) } + async prepareSdkStreamingAttempt(input: SdkStreamingAttemptInput): Promise { + const { sdkResponse, model, conversationId, lifecycle, recoveryMode } = input + const wrapped = wrapSdkEventStream( + sdkResponse, + lifecycle.signal, + lifecycle.onUpstreamWaitStart, + lifecycle.onUpstreamWaitEnd, + lifecycle.onIterationError + ) + const reasoning = new ReasoningAccumulator() + const emitted = lifecycle.emittedOutput ?? new EmittedOutputAccumulator() + const transformed = transformSdkStream( + wrapped.response, + model, + conversationId, + reasoning, + lifecycle.streamObserver + ) + const prefetched: unknown[] = [] + let prefetchIndex = 0 + let drained = false + let closed = false + + const close = async (): Promise => { + if (closed) return + closed = true + await Promise.allSettled([transformed.return(undefined)]) + await wrapped.closeRaw() + } + const readNext = async (): Promise> => { + if (drained) return { done: true, value: undefined } + const item = await transformed.next() + if (item.done) { + if (!wrapped.completionMetadataSeen()) lifecycle.onCleanEofWithoutCompletionMetadata?.() + if (isSemanticTruncation(recoveryMode, wrapped, emitted, lifecycle.streamObserver)) { + throw new SdkEventStreamIterationError(new SemanticStreamTruncationError()) + } + drained = true + return { done: true, value: undefined } + } + emitted.observeChunk(item.value) + return item + } + + try { + while (true) { + const item = await readNext() + if (item.done) break + prefetched.push(item.value) + if (isSemanticChunk(item.value)) break + } + } catch (error) { + await close() + throw error + } + + return { + chunks: { + next: async () => { + if (prefetchIndex < prefetched.length) { + const prefetchedChunk = prefetched[prefetchIndex] + prefetchIndex++ + return { done: false, value: prefetchedChunk } + } + return readNext() + }, + return: async () => { + await close() + return { done: true, value: undefined } + } + }, + observed: () => ({ + emitted: { + visibleChars: emitted.visibleText.length, + toolCount: emitted.toolUses().length + }, + sawToolIntent: lifecycle.streamObserver?.sawToolIntent ?? false + }), + close, + complete: (completion) => + this.fireCompletion(lifecycle, reasoning, emitted, model, completion.recovered) + } + } + async handleSuccess( response: Response, model: string, @@ -313,7 +441,17 @@ export class ResponseHandler { // marker fires here, before completion, rather than at each `item.done`. const complete = async (): Promise => { if (!wrapped.completionMetadataSeen()) lifecycle.onCleanEofWithoutCompletionMetadata?.() - return this.fireCompletion(lifecycle, reasoning, emitted, model) + if ( + isSemanticTruncation( + lifecycle.recoveryMode ?? 'off', + wrapped, + emitted, + lifecycle.streamObserver + ) + ) { + throw new SdkEventStreamIterationError(new SemanticStreamTruncationError()) + } + return this.fireCompletion(lifecycle, reasoning, emitted, model, false) } if (lifecycle.bufferUntilComplete) { diff --git a/src/core/request/stream-log-events.ts b/src/core/request/stream-log-events.ts new file mode 100644 index 0000000..1fc6fbc --- /dev/null +++ b/src/core/request/stream-log-events.ts @@ -0,0 +1,5 @@ +/** Stable denominator event written once per inbound streaming request. */ +export const STREAM_REQUEST_STARTED_LOG = 'Kiro stream request started' + +/** Stable marker for a clean SDK `done` without completion metadata. */ +export const STREAM_MISSING_COMPLETION_LOG = 'Kiro stream ended without completion metadata' diff --git a/src/core/request/stream-recovery.ts b/src/core/request/stream-recovery.ts index 55f5397..44bdde7 100644 --- a/src/core/request/stream-recovery.ts +++ b/src/core/request/stream-recovery.ts @@ -46,6 +46,8 @@ export type StreamRecoveryOptions = { readonly mode: StreamRecoveryMode readonly maxAttempts: number readonly signal: AbortSignal + /** Already primed through the first semantic chunk so pre-output failures stay caller-owned. */ + readonly initialAttempt?: AttemptHandle readonly attemptFactory: AttemptFactory /** Receives the one-based index of the failed attempt being backed off. */ readonly delayFn: (attemptIndex: number, signal: AbortSignal) => Promise @@ -111,6 +113,10 @@ export class StreamRecoveryCoordinator { throw new RangeError('maxAttempts must be a positive integer') } this.options = options + if (options.initialAttempt) { + this.activeAttempt = options.initialAttempt + this.attemptIndex = 1 + } this.stream = new ReadableStream( { start: (controller) => this.start(controller), From ddd4a62c0be650c955064a202756dd1a73908f75 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 13:53:33 +0800 Subject: [PATCH 08/21] =?UTF-8?q?test(request):=20=E8=A1=A5=E9=BD=90=20Tie?= =?UTF-8?q?r=20A=20=E6=81=A2=E5=A4=8D=E6=95=85=E9=9A=9C=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按计划 §9 逐行核对 Phase 1(Tier A)矩阵,补齐 T1.1–T1.5 未覆盖的 10 例, 全部走 RequestHandler.handle 集成路径(不重复 coordinator 单测): - row 1:reasoning_restart 下输出前失败仍由既有 legacy 重试处理,判别式用 legacy `retrying` 记录独有的 `error` 字段(coordinator 退避记录不带) - row 3:reasoning 已结束、text 未开始(内联 关块)仍 Tier A 命中 - row 7:completion metadata 后断在恢复模式下仍走 ignored 路径、零额外发送 - row 8:恢复 attempt 迭代中 caller abort 即时终止、无后续发送、队列可用 - row 9:耗尽时恰好 stream_max_attempts 次发送 + 2 条 retrying + 1 条终端日志 - row 10:并发同账号在恢复模式下两条健康流签名均正常 publish - 空上游流(零事件)在恢复模式下保持成功,不被判为语义截断 - 恢复流 SSE 字节形状:分帧完整、通道顺序正确、唯一终端帧且位于末尾 - 45% 指标分母在发生恢复时仍恰一条 - 恢复路径 retry/终端日志脱敏(reasoning-log-redaction 的 wireHandler 追加 可选 configOverrides 参数,既有调用点零改动) 反向证伪四轮(legacy error 字段、decideRecoveryTier、语义截断谓词的两个 条件)均按预期变红。生产代码零改动:矩阵未暴露 bug。 --- src/__tests__/reasoning-log-redaction.test.ts | 45 +- src/__tests__/request-handler.test.ts | 393 ++++++++++++++++++ 2 files changed, 436 insertions(+), 2 deletions(-) diff --git a/src/__tests__/reasoning-log-redaction.test.ts b/src/__tests__/reasoning-log-redaction.test.ts index 118c8d3..2d0403a 100644 --- a/src/__tests__/reasoning-log-redaction.test.ts +++ b/src/__tests__/reasoning-log-redaction.test.ts @@ -327,7 +327,11 @@ function makeAccount(): ManagedAccount { } } -function wireHandler(prep: SdkPreparedRequest, send: () => Promise): RequestHandler { +function wireHandler( + prep: SdkPreparedRequest, + send: () => Promise, + configOverrides: Record = {} +): RequestHandler { const account = makeAccount() const accountManager: any = { getAccounts: () => [account], @@ -358,7 +362,8 @@ function wireHandler(prep: SdkPreparedRequest, send: () => Promise): Re auto_effort_mapping: false, token_expiry_buffer_ms: 120000, auto_sync_kiro_cli: false, - account_selection_strategy: 'sticky' + account_selection_strategy: 'sticky', + ...configOverrides } const handler = new RequestHandler(accountManager, config, { save: mock(async () => {}), @@ -430,6 +435,42 @@ describe('§6.8 redaction — stream observability fields', () => { expectNoLeak(text) }) + test('recovery-path retry and terminal logs carry volume only', async () => { + let sends = 0 + const handler = wireHandler( + streamingPrep(), + async () => { + sends++ + const attempt = sends + return { + generateAssistantResponseResponse: (async function* () { + yield { reasoningContentEvent: { text: REASONING } } + throw new Error(`recovery attempt ${attempt} died`) + })() + } + }, + { stream_max_attempts: 2, stream_recovery_mode: 'reasoning_restart' } + ) + ;(handler as unknown as { sleep: () => Promise }).sleep = async () => {} + + await drain( + await handler.handle( + KIRO_URL, + { body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'go' }] }) }, + noToast + ) + ) + + expect(sends).toBe(2) + const text = allLogText() + expect(text).toContain('"outcome":"retrying"') + expect(text).toContain('"outcome":"terminated_after_output"') + expect(text).toContain(`"emittedReasoningChars":${REASONING.length}`) + expect(text).toContain('"emittedVisibleChars":0') + expect(text).not.toContain(REASONING) + expectNoLeak(text) + }) + test('the missing-completion-metadata marker carries volume only', async () => { const handler = wireHandler( streamingPrep(), diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index 476281e..b1b690e 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1545,6 +1545,36 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { }) }) + test('recovery mode leaves two healthy concurrent requests on one account publishing', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc, acc], + sdkResults: [ + sdkStream(signedToolEvents('live-one')), + sdkStream(signedToolEvents('live-two')) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + + const first = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const second = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await Promise.all([first.text(), second.text()]) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(lookupSignedTool('live-one').envelope).toEqual({ + kind: 'reasoningText', + text: 'reasoning-live-one', + signature: 'signature-live-one' + }) + expect(lookupSignedTool('live-two').envelope).toEqual({ + kind: 'reasoningText', + text: 'reasoning-live-two', + signature: 'signature-live-two' + }) + }) + test('a recovered tool completion does not publish its final-attempt envelope', async () => { const acc = makeAccount({ id: 'A' }) const { handler, fakes } = buildHandler({ @@ -2561,6 +2591,369 @@ describe('RequestHandler.handle — API request logging', () => { }) }) +describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', () => { + function sseFrames(body: string): Array> { + const raw = body.split('\n\n') + expect(raw.at(-1)).toBe('') + return raw.slice(0, -1).map((frame) => { + expect(frame.startsWith('data: ')).toBe(true) + return JSON.parse(frame.slice('data: '.length)) as Record + }) + } + + function records( + spy: ReturnType['warn'], + message: string + ): Array> { + return spy.mock.calls + .filter((call) => call[0] === message) + .map((call) => call[1] as Record) + } + + const STREAM_FAILURE_LOG = 'Kiro SDK event stream iteration failed' + + test('row 1: a pre-output failure in recovery mode stays on the legacy retry path', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([], new Error('pre-output decode failure')), + sdkStream([ + { assistantResponseEvent: { content: 'second attempt answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(frames.map((frame) => frame.choices?.[0]?.delta?.content ?? '').join('')).toBe( + 'second attempt answer' + ) + expect(frames.filter((frame) => frame.choices?.[0]?.finish_reason !== null)).toHaveLength(1) + + const retrying = records(logs.warn, STREAM_FAILURE_LOG) + expect(retrying).toHaveLength(1) + expect(retrying[0]).toMatchObject({ + outcome: 'retrying', + emittedReasoningChars: 0, + emittedVisibleChars: 0 + }) + // The legacy pre-output retry is the only path that attaches the failure + // cause to its `retrying` record; the coordinator's backoff never does. + expect(retrying[0]!['error']).toMatchObject({ + name: 'SdkEventStreamIterationError', + cause: { message: 'pre-output decode failure' } + }) + } finally { + logs.restore() + } + }) + + test('row 3: reasoning closed before any text is still eligible for restart', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + // The inline dialect closes the reasoning block without emitting any + // visible text, so the break lands after reasoning ended, before text. + sdkStream( + [{ assistantResponseEvent: { content: 'closed reasoning' } }], + new Error('reset after reasoning closed') + ), + sdkStream([ + { reasoningContentEvent: { text: 'restarted reasoning' } }, + { assistantResponseEvent: { content: 'final answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 2 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect( + frames.map((frame) => frame.choices?.[0]?.delta?.reasoning_content ?? '').join('') + ).toBe('closed reasoningrestarted reasoning') + expect(frames.map((frame) => frame.choices?.[0]?.delta?.content ?? '').join('')).toBe( + 'final answer' + ) + expect(records(logs.warn, STREAM_FAILURE_LOG)[0]).toMatchObject({ + outcome: 'retrying', + emittedReasoningChars: 'closed reasoning'.length, + emittedVisibleChars: 0 + }) + } finally { + logs.restore() + } + }) + + test('row 7: a break after completion metadata stays ignored in recovery mode', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const terminated = new TypeError('terminated', { + cause: Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }) + }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { assistantResponseEvent: { content: 'complete response' } }, + { metadataEvent: { tokenUsage: { inputTokens: 4, outputTokens: 2 } } } + ], + terminated + ), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(streamedText(body)).toBe('complete response') + expect(body).toContain('"finish_reason":"stop"') + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect( + records(logs.log, 'Kiro SDK event stream closed after completion metadata')[0] + ).toMatchObject({ outcome: 'ignored_after_completion_metadata' }) + expect( + [...logs.log.mock.calls, ...logs.warn.mock.calls, ...logs.error.mock.calls].some( + (call) => call[0] === STREAM_FAILURE_LOG + ) + ).toBe(false) + } finally { + logs.restore() + } + }) + + test('row 8: caller abort while a recovery attempt is iterating issues no further send', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler } = buildHandler({ + selectResults: [acc, acc], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + const internals = handler as unknown as { + makeSdkClient: () => { send: () => Promise } + } + let sendCalls = 0 + const iterationStalled = Promise.withResolvers() + internals.makeSdkClient = () => ({ + send: async () => { + sendCalls++ + if (sendCalls === 1) { + return sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('late reset') + ) + } + if (sendCalls === 2) { + return { + generateAssistantResponseResponse: (async function* () { + yield { reasoningContentEvent: { text: 'recovered reasoning' } } + iterationStalled.resolve() + await new Promise(() => {}) + })() + } + } + return sdkStream([ + { assistantResponseEvent: { content: 'later request succeeds' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + } + }) + + const controller = new AbortController() + const response = await handler.handle( + KIRO_URL, + { body: JSON.stringify({}), signal: controller.signal }, + noToast + ) + const reading = response.text() + + await iterationStalled.promise + controller.abort(new DOMException('cancelled during recovery iteration', 'AbortError')) + + await expect(reading).rejects.toMatchObject({ name: 'AbortError' }) + expect(sendCalls).toBe(2) + const next = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + expect(streamedText(await next.text())).toBe('later request succeeds') + expect(sendCalls).toBe(3) + }) + + test('row 9: exhaustion sends exactly stream_max_attempts times and terminates once', async () => { + const acc = makeAccount({ id: 'A' }) + const failure = new Error('persistent reasoning failure') + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([{ reasoningContentEvent: { text: 'attempt one' } }], failure), + sdkStream([{ reasoningContentEvent: { text: 'attempt two' } }], failure), + sdkStream([{ reasoningContentEvent: { text: 'attempt three' } }], failure), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart', + streamMaxAttempts: 3 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(3) + expect(records(logs.warn, STREAM_FAILURE_LOG)).toHaveLength(2) + const terminal = records(logs.error, STREAM_FAILURE_LOG) + expect(terminal).toHaveLength(1) + expect(terminal[0]).toMatchObject({ outcome: 'terminated_after_output', emittedOutput: true }) + } finally { + logs.restore() + } + }) + + test('an empty upstream stream in recovery mode stays a success, not a truncation', async () => { + const acc = makeAccount({ id: 'A', failCount: 2, unhealthyReason: 'transient' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([]), sdkStream([{ assistantResponseEvent: { content: 'unused' } }])], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(frames.map((frame) => frame.choices?.[0]?.finish_reason)).toEqual(['stop']) + expect(acc.failCount).toBe(0) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect( + [...logs.warn.mock.calls, ...logs.error.mock.calls].some( + (call) => call[0] === STREAM_FAILURE_LOG + ) + ).toBe(false) + // The truncation predicate needs reasoning or content, so a zero-event + // stream is only ever marked, never turned into a recoverable failure. + expect(records(logs.warn, STREAM_MISSING_COMPLETION_LOG)[0]).toMatchObject({ + outcome: 'clean_eof_without_completion_metadata', + emittedReasoningChars: 0, + emittedVisibleChars: 0 + }) + } finally { + logs.restore() + } + }) + + test('a recovered stream is one well-framed SSE sequence with a single terminal chunk', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'first half ' } }], + new Error('reset mid reasoning') + ), + sdkStream([ + { reasoningContentEvent: { text: 'second half' } }, + { assistantResponseEvent: { content: 'final answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 2, totalTokens: 2 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(frames.map((frame) => frame.choices?.[0]?.delta?.reasoning_content ?? '').join('')).toBe( + 'first half second half' + ) + expect(frames.map((frame) => frame.choices?.[0]?.delta?.content ?? '').join('')).toBe( + 'final answer' + ) + const terminalPositions = frames + .map((frame, index) => ({ index, finish: frame.choices?.[0]?.finish_reason })) + .filter((entry) => entry.finish !== null && entry.finish !== undefined) + expect(terminalPositions).toEqual([{ index: frames.length - 1, finish: 'stop' }]) + const lastReasoning = frames.findLastIndex( + (frame) => frame.choices?.[0]?.delta?.reasoning_content !== undefined + ) + const firstContent = frames.findIndex( + (frame) => frame.choices?.[0]?.delta?.content !== undefined + ) + expect(lastReasoning).toBeLessThan(firstContent) + }) + + test('a live recovery writes no extra stream-start record', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('late reset') + ), + sdkStream([ + { assistantResponseEvent: { content: 'recovered answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(records(logs.log, STREAM_REQUEST_STARTED_LOG)).toHaveLength(1) + } finally { + logs.restore() + } + }) +}) + describe('RequestHandler.handle — circuit breaker', () => { test('exceeding max_request_iterations throws the retry-strategy error', async () => { globalThis.setTimeout = ((fn: any) => { From 232d7fb7080c8d67a9b14b9a3374c1ceb84c0621 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 14:25:48 +0800 Subject: [PATCH 09/21] =?UTF-8?q?feat(request):=20Tier=20B=20=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E5=BD=B1=E5=AD=90=E9=87=8D=E6=94=BE=EF=BC=88=E4=B8=89?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=89=8D=E7=BC=80=E8=BF=BD=E5=B9=B3=20+=20ex?= =?UTF-8?q?act=5Freplay=20=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/config-loader.test.ts | 14 +- src/__tests__/replay-matcher.test.ts | 137 ++++++++++ src/__tests__/request-handler.test.ts | 89 ++++++- .../stream-recovery-exact-replay.test.ts | 252 ++++++++++++++++++ src/__tests__/stream-recovery.fixture.ts | 8 +- src/__tests__/stream-recovery.test.ts | 8 +- src/core/request/recovery-integration.ts | 11 +- src/core/request/replay-matcher.ts | 222 +++++++++++++++ src/core/request/request-handler.ts | 3 +- src/core/request/response-handler.ts | 8 +- src/core/request/stream-recovery.ts | 148 ++++++++-- src/plugin/config/schema.ts | 4 +- 12 files changed, 877 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/replay-matcher.test.ts create mode 100644 src/__tests__/stream-recovery-exact-replay.test.ts create mode 100644 src/core/request/replay-matcher.ts diff --git a/src/__tests__/config-loader.test.ts b/src/__tests__/config-loader.test.ts index 4c82f12..e29458f 100644 --- a/src/__tests__/config-loader.test.ts +++ b/src/__tests__/config-loader.test.ts @@ -184,8 +184,13 @@ describe('loadConfig env overrides', () => { expect(loadConfig(projectDir).stream_recovery_mode).toBe('reasoning_restart') }) - test('invalid recovery mode env falls back to off (schema .catch)', () => { + test('KIRO_STREAM_RECOVERY_MODE accepts the exact replay strategy', () => { process.env.KIRO_STREAM_RECOVERY_MODE = 'exact_replay' + expect(loadConfig(projectDir).stream_recovery_mode).toBe('exact_replay') + }) + + test('invalid recovery mode env falls back to off (schema .catch)', () => { + process.env.KIRO_STREAM_RECOVERY_MODE = 'hybrid_experimental' expect(loadConfig(projectDir).stream_recovery_mode).toBe('off') }) @@ -286,10 +291,15 @@ describe('loadConfig file merge', () => { writeUserConfig({ stream_recovery_mode: 'reasoning_restart' }) expect(loadConfig(projectDir).stream_recovery_mode).toBe('reasoning_restart') - writeUserConfig({ stream_recovery_mode: 'exact_replay' }) + writeUserConfig({ stream_recovery_mode: 'hybrid_experimental' }) expect(loadConfig(projectDir).stream_recovery_mode).toBe('off') }) + test('user file accepts the exact replay recovery mode', () => { + writeUserConfig({ stream_recovery_mode: 'exact_replay' }) + expect(loadConfig(projectDir).stream_recovery_mode).toBe('exact_replay') + }) + test('the config literal union stays assignable to the coordinator mode', () => { const forCoordinator: CoordinatorStreamRecoveryMode = loadConfig(projectDir).stream_recovery_mode diff --git a/src/__tests__/replay-matcher.test.ts b/src/__tests__/replay-matcher.test.ts new file mode 100644 index 0000000..2440eaf --- /dev/null +++ b/src/__tests__/replay-matcher.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test' +import { + ExactReplayMatcher, + type ReplayMatchResult, + type ReplayPrefix +} from '../core/request/replay-matcher.js' + +function chunk( + delta: Readonly>, + finishReason: string | null = null +): unknown { + return { choices: [{ delta, finish_reason: finishReason }] } +} + +function released(result: ReplayMatchResult): readonly unknown[] { + expect(result.kind).toBe('release') + return result.kind === 'release' ? result.chunks : [] +} + +function contentOf(value: unknown, field: 'content' | 'reasoning_content'): string | undefined { + if (typeof value !== 'object' || value === null || !('choices' in value)) return undefined + const choices = value.choices + if (!Array.isArray(choices)) return undefined + const first = choices[0] + if (typeof first !== 'object' || first === null || !('delta' in first)) return undefined + const delta = first.delta + if (typeof delta !== 'object' || delta === null || !(field in delta)) return undefined + const content = delta[field] + return typeof content === 'string' ? content : undefined +} + +const TEXT_PREFIX: ReplayPrefix = { + reasoningText: '', + visibleText: 'hello world', + toolUses: [] +} + +describe('ExactReplayMatcher', () => { + test('catches up across different text chunk boundaries and releases only the suffix', () => { + // Given + const matcher = new ExactReplayMatcher(TEXT_PREFIX) + + // When + const first = matcher.consume(chunk({ content: 'hel' })) + const second = matcher.consume(chunk({ content: 'lo world!' })) + + // Then + expect(first).toEqual({ kind: 'withheld' }) + const suffix = released(second) + expect(suffix).toHaveLength(1) + expect(contentOf(suffix[0], 'content')).toBe('!') + expect(matcher.progress()).toEqual({ + matchedReasoningChars: 0, + matchedVisibleChars: 11, + matchedToolCount: 0 + }) + }) + + test('withholds suffix chunks until every independent channel catches up', () => { + // Given + const matcher = new ExactReplayMatcher({ + reasoningText: 'think', + visibleText: 'answer', + toolUses: [] + }) + + // When + const text = matcher.consume(chunk({ content: 'answer+' })) + const reasoning = matcher.consume(chunk({ reasoning_content: 'think?' })) + + // Then + expect(text).toEqual({ kind: 'withheld' }) + const suffix = released(reasoning) + expect(suffix).toHaveLength(2) + expect(contentOf(suffix[0], 'content')).toBe('+') + expect(contentOf(suffix[1], 'reasoning_content')).toBe('?') + }) + + test('rejects a reasoning or text byte mismatch without releasing buffered output', () => { + // Given + const reasoningMatcher = new ExactReplayMatcher({ + reasoningText: 'expected', + visibleText: '', + toolUses: [] + }) + const textMatcher = new ExactReplayMatcher(TEXT_PREFIX) + + // When + const reasoning = reasoningMatcher.consume(chunk({ reasoning_content: 'expectXd' })) + const text = textMatcher.consume(chunk({ content: 'hello wurld' })) + + // Then + expect(reasoning).toEqual({ kind: 'diverged', channel: 'reasoning' }) + expect(text).toEqual({ kind: 'diverged', channel: 'text' }) + expect(reasoningMatcher.progress().matchedReasoningChars).toBe(6) + expect(textMatcher.progress().matchedVisibleChars).toBe(7) + }) + + test('normalizes tool arguments while preserving ordered id and name matching', () => { + // Given + const prefix: ReplayPrefix = { + reasoningText: '', + visibleText: '', + toolUses: [ + { toolUseId: 'tool-1', name: 'read', argumentsJson: '{"path":"/a"}' }, + { toolUseId: 'tool-2', name: 'write', argumentsJson: '{"path":"/b"}' } + ] + } + const matching = new ExactReplayMatcher(prefix) + const reordered = new ExactReplayMatcher(prefix) + const matchingCalls = [ + { index: 0, id: 'tool-1', function: { name: 'read', arguments: '{ "path": "/a" }' } }, + { index: 1, id: 'tool-2', function: { name: 'write', arguments: '{"path":"/b"}' } } + ] + + // When + const caughtUp = matching.consume(chunk({ tool_calls: matchingCalls })) + const diverged = reordered.consume(chunk({ tool_calls: [matchingCalls[1], matchingCalls[0]] })) + + // Then + expect(caughtUp).toEqual({ kind: 'release', chunks: [], caughtUp: true }) + expect(diverged).toEqual({ kind: 'diverged', channel: 'tool' }) + }) + + test('treats a terminal chunk before full prefix coverage as an early-end divergence', () => { + // Given + const matcher = new ExactReplayMatcher(TEXT_PREFIX) + matcher.consume(chunk({ content: 'hello' })) + + // When + const result = matcher.consume(chunk({}, 'stop')) + + // Then + expect(result).toEqual({ kind: 'diverged', channel: 'early_end' }) + expect(matcher.progress().matchedVisibleChars).toBe(5) + }) +}) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index b1b690e..bdc0ec2 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -114,7 +114,7 @@ function buildHandler(opts: { streamEventTimeoutEnabled?: boolean streamBufferUntilComplete?: boolean streamMaxAttempts?: number - streamRecoveryMode?: 'off' | 'reasoning_restart' + streamRecoveryMode?: 'off' | 'reasoning_restart' | 'exact_replay' maxRequestIterations?: number sdkResponseTimeoutEnabled?: boolean sdkResponseTimeoutMs?: number @@ -1078,6 +1078,61 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { expect(acc.failCount).toBe(0) }) + test('exact replay logs divergence and catch-up volumes without replay content', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ assistantResponseEvent: { content: 'prefix-0123456789' } }], + new Error('first reset') + ), + sdkStream([{ assistantResponseEvent: { content: 'preXix-0123456789' } }]), + sdkStream([ + { assistantResponseEvent: { content: 'prefix- suffix' } }, + { metadataEvent: { tokenUsage: { outputTokens: 2 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(logs.log).toHaveBeenCalledWith( + 'Kiro exact replay attempt finished', + expect.objectContaining({ + matchedReasoningChars: 0, + matchedVisibleChars: 3, + matchedToolCount: 0, + divergenceChannel: 'text', + replayOutcome: 'diverged', + attempts: 2, + quotaNote: 'each exact replay attempt consumes one real SDK send' + }) + ) + expect(logs.log).toHaveBeenCalledWith( + 'Kiro exact replay attempt finished', + expect.objectContaining({ + matchedReasoningChars: 0, + matchedVisibleChars: 7, + matchedToolCount: 0, + divergenceChannel: 'none', + replayOutcome: 'caught_up', + attempts: 3, + quotaNote: 'each exact replay attempt consumes one real SDK send' + }) + ) + } finally { + logs.restore() + } + }) + test('the first live recovery reuses its account and a later recovery prefers an alternative', async () => { const a = makeAccount({ id: 'A' }) const b = makeAccount({ id: 'B' }) @@ -1599,6 +1654,38 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { expect(lookupSignedTool('recovered').refusal).toBe('miss') }) + test('a caught-up exact replay publishes its final-attempt envelope', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'reasoning-exact' } }, + { reasoningContentEvent: { signature: 'signature-exact' } }, + { assistantResponseEvent: { content: 'visible-exact' } } + ], + new Error('late reset') + ), + sdkStream(signedToolEvents('exact')) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + await response.text() + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(lookupSignedTool('exact').envelope).toEqual({ + kind: 'reasoningText', + text: 'reasoning-exact', + signature: 'signature-exact' + }) + }) + test('a superseded attempt cannot republish after the recovered final answer clears its loop', async () => { const acc = makeAccount({ id: 'A' }) const loopId = 'loop-superseded-attempt' diff --git a/src/__tests__/stream-recovery-exact-replay.test.ts b/src/__tests__/stream-recovery-exact-replay.test.ts new file mode 100644 index 0000000..bd64217 --- /dev/null +++ b/src/__tests__/stream-recovery-exact-replay.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from 'bun:test' +import { decideRecoveryTier } from '../core/request/stream-recovery.js' +import { + TestStreamFailure, + chunk, + collect, + createHarness, + makeAttempt +} from './stream-recovery.fixture.js' + +const VISIBLE_OBSERVATION = { + emitted: { visibleChars: 11, toolCount: 0 }, + sawToolIntent: false +} as const + +function toolCall(index: number, id: string, name: string, argumentsJson: string): unknown { + return { index, id, function: { name, arguments: argumentsJson } } +} + +describe('exact replay recovery tier', () => { + test('downgrades to reasoning_restart when Tier A remains eligible', () => { + // Given + const input = { + mode: 'exact_replay', + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: false + } as const + + // When / Then + expect(decideRecoveryTier(input)).toBe('reasoning_restart') + }) + + test('selects exact_replay only after visible or tool output was delivered', () => { + // Given + const visible = { + mode: 'exact_replay', + emitted: { visibleChars: 1, toolCount: 0 }, + sawToolIntent: false + } as const + const tool = { + mode: 'exact_replay', + emitted: { visibleChars: 0, toolCount: 1 }, + sawToolIntent: true + } as const + const intentOnly = { + mode: 'exact_replay', + emitted: { visibleChars: 0, toolCount: 0 }, + sawToolIntent: true + } as const + + // When / Then + expect([visible, tool, intentOnly].map(decideRecoveryTier)).toEqual([ + 'exact_replay', + 'exact_replay', + 'none' + ]) + }) +}) + +describe('StreamRecoveryCoordinator exact replay', () => { + test('matches different text chunk splits and releases only the new suffix plus one terminal', async () => { + // Given + const first = makeAttempt({ + output: [chunk('first-1', { content: 'hello ' }), chunk('first-2', { content: 'world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('failed after visible prefix') + }) + const replay = makeAttempt({ + output: [ + chunk('shadow-1', { content: 'hel' }), + chunk('suffix', { content: 'lo world!' }), + chunk('finish', {}, 'stop') + ] + }) + const harness = createHarness([first, replay], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-1', 'first-2', 'suffix', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2]) + expect(harness.completions).toEqual([ + { attemptIndex: 2, recoveryTier: 'exact_replay', recovered: false } + ]) + expect(harness.replayTelemetry).toEqual([ + { + matchedReasoningChars: 0, + matchedVisibleChars: 11, + matchedToolCount: 0, + divergenceChannel: 'none', + replayOutcome: 'caught_up', + attempts: 2 + } + ]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('consumes a text-divergent replay budget slot without leaking its chunk', async () => { + // Given + const first = makeAttempt({ + output: [chunk('first', { content: 'hello world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('first failure') + }) + const divergent = makeAttempt({ output: [chunk('must-not-leak', { content: 'hello wurld' })] }) + const recovered = makeAttempt({ + output: [chunk('suffix', { content: 'hello world!' }), chunk('finish', {}, 'stop')] + }) + const harness = createHarness([first, divergent, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first', 'suffix', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2, 3]) + expect( + harness.replayTelemetry.map(({ replayOutcome, divergenceChannel }) => ({ + replayOutcome, + divergenceChannel + })) + ).toEqual([ + { replayOutcome: 'diverged', divergenceChannel: 'text' }, + { replayOutcome: 'caught_up', divergenceChannel: 'none' } + ]) + }) + + test('consumes a reasoning-divergent replay budget slot without leaking its chunk', async () => { + // Given + const first = makeAttempt({ + output: [ + chunk('first-reasoning', { reasoning_content: 'think' }), + chunk('first-text', { content: 'answer' }) + ], + observation: { emitted: { visibleChars: 6, toolCount: 0 }, sawToolIntent: false }, + failure: new TestStreamFailure('first failure') + }) + const divergent = makeAttempt({ + output: [chunk('must-not-leak', { reasoning_content: 'thunk', content: 'answer' })] + }) + const recovered = makeAttempt({ + output: [ + chunk('matched-reasoning', { reasoning_content: 'think' }), + chunk('suffix', { content: 'answer!' }), + chunk('finish', {}, 'stop') + ] + }) + const harness = createHarness([first, divergent, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-reasoning', 'first-text', 'suffix', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2, 3]) + expect(harness.replayTelemetry[0]).toEqual({ + matchedReasoningChars: 2, + matchedVisibleChars: 0, + matchedToolCount: 0, + divergenceChannel: 'reasoning', + replayOutcome: 'diverged', + attempts: 2 + }) + }) + + test('rejects reordered tools and catches up on a later replay without duplicating tools', async () => { + // Given + const firstCalls = [ + toolCall(0, 'tool-1', 'read', '{"path":"/a"}'), + toolCall(1, 'tool-2', 'write', '{"path":"/b"}') + ] + const first = makeAttempt({ + output: [chunk('first-tools', { tool_calls: firstCalls })], + observation: { emitted: { visibleChars: 0, toolCount: 2 }, sawToolIntent: true }, + failure: new TestStreamFailure('failed after tools') + }) + const reordered = makeAttempt({ + output: [chunk('must-not-leak', { tool_calls: [firstCalls[1], firstCalls[0]] })] + }) + const recovered = makeAttempt({ + output: [ + chunk('matched-tools', { tool_calls: firstCalls }), + chunk('finish', {}, 'tool_calls') + ] + }) + const harness = createHarness([first, reordered, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-tools', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2, 3]) + expect(harness.replayTelemetry[0]?.divergenceChannel).toBe('tool') + }) + + test('treats an early replay terminal as divergence and spends another attempt', async () => { + // Given + const first = makeAttempt({ + output: [chunk('first', { content: 'hello world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('first failure') + }) + const early = makeAttempt({ + output: [chunk('shadow', { content: 'hello' }), chunk('must-not-leak', {}, 'stop')] + }) + const recovered = makeAttempt({ + output: [chunk('suffix', { content: 'hello world!' }), chunk('finish', {}, 'stop')] + }) + const harness = createHarness([first, early, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first', 'suffix', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2, 3]) + expect(harness.replayTelemetry[0]?.divergenceChannel).toBe('early_end') + }) + + test('reports a replay stream failure before catch-up without leaking its shadow bytes', async () => { + // Given + const first = makeAttempt({ + output: [chunk('first', { content: 'hello world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('first failure') + }) + const failed = makeAttempt({ + output: [chunk('must-not-leak', { content: 'hello' })], + failure: new TestStreamFailure('replay transport failure') + }) + const recovered = makeAttempt({ + output: [chunk('suffix', { content: 'hello world!' }), chunk('finish', {}, 'stop')] + }) + const harness = createHarness([first, failed, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first', 'suffix', 'finish']) + expect(harness.replayTelemetry[0]).toEqual({ + matchedReasoningChars: 0, + matchedVisibleChars: 5, + matchedToolCount: 0, + divergenceChannel: 'none', + replayOutcome: 'failed', + attempts: 2 + }) + }) +}) diff --git a/src/__tests__/stream-recovery.fixture.ts b/src/__tests__/stream-recovery.fixture.ts index 4a54dff..ec5b0be 100644 --- a/src/__tests__/stream-recovery.fixture.ts +++ b/src/__tests__/stream-recovery.fixture.ts @@ -3,6 +3,7 @@ import { StreamRecoveryCoordinator, type AttemptHandle, type AttemptObservation, + type ReplayAttemptTelemetry, type StreamRecoveryCompletion, type StreamRecoveryMode } from '../core/request/stream-recovery.js' @@ -38,7 +39,7 @@ export const ELIGIBLE = { export function chunk( label: string, - delta: Readonly> = {}, + delta: Readonly> = {}, finishReason: string | null = null ): unknown { return { label, choices: [{ delta, finish_reason: finishReason }] } @@ -81,6 +82,7 @@ export function createHarness( ) { const requestedAttempts: number[] = [] const completions: StreamRecoveryCompletion[] = [] + const replayTelemetry: ReplayAttemptTelemetry[] = [] let terminalCalls = 0 const signal = overrides.signal ?? new AbortController().signal const coordinator = new StreamRecoveryCoordinator({ @@ -101,6 +103,9 @@ export function createHarness( onComplete: (completion) => { completions.push(completion) }, + onReplayAttempt: (telemetry) => { + replayTelemetry.push(telemetry) + }, onTerminal: () => { terminalCalls++ } @@ -109,6 +114,7 @@ export function createHarness( coordinator, requestedAttempts, completions, + replayTelemetry, terminalCalls: () => terminalCalls } } diff --git a/src/__tests__/stream-recovery.test.ts b/src/__tests__/stream-recovery.test.ts index b1c3290..e4b1464 100644 --- a/src/__tests__/stream-recovery.test.ts +++ b/src/__tests__/stream-recovery.test.ts @@ -55,7 +55,9 @@ describe('StreamRecoveryCoordinator', () => { // Then expect(labels).toEqual(['reasoning-1', 'reasoning-2', 'finish']) expect(harness.requestedAttempts).toEqual([1, 2]) - expect(harness.completions).toEqual([{ attemptIndex: 2, recovered: true }]) + expect(harness.completions).toEqual([ + { attemptIndex: 2, recoveryTier: 'reasoning_restart', recovered: true } + ]) expect(harness.terminalCalls()).toBe(1) }) @@ -193,7 +195,9 @@ describe('StreamRecoveryCoordinator', () => { const labels = await collect(harness.coordinator.stream) // Then expect(labels).toEqual(['reasoning']) - expect(harness.completions).toEqual([{ attemptIndex: 2, recovered: true }]) + expect(harness.completions).toEqual([ + { attemptIndex: 2, recoveryTier: 'reasoning_restart', recovered: true } + ]) expect(harness.terminalCalls()).toBe(1) }) diff --git a/src/core/request/recovery-integration.ts b/src/core/request/recovery-integration.ts index 83e3d57..0e57795 100644 --- a/src/core/request/recovery-integration.ts +++ b/src/core/request/recovery-integration.ts @@ -80,7 +80,7 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): const attempt = completedAttempt if (!attempt) throw new Error('No completed Kiro recovery attempt is available') await attempt.complete(completion) - if (options.priorStreamFailures > 0 || completion.recovered) { + if (options.priorStreamFailures > 0 || completion.recoveryTier !== 'none') { logger.log( 'Kiro SDK event stream retry recovered', activeLogDetails({ @@ -90,6 +90,15 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): ) } }, + onReplayAttempt: (telemetry) => { + logger.log( + 'Kiro exact replay attempt finished', + activeLogDetails({ + ...telemetry, + quotaNote: 'each exact replay attempt consumes one real SDK send' + }) + ) + }, onTerminal: options.onTerminal, onCancel: options.onCancel }) diff --git a/src/core/request/replay-matcher.ts b/src/core/request/replay-matcher.ts new file mode 100644 index 0000000..dfc1ba5 --- /dev/null +++ b/src/core/request/replay-matcher.ts @@ -0,0 +1,222 @@ +import { + EmittedOutputAccumulator, + type EmittedToolUse +} from '../../plugin/reasoning/emitted-output.js' +import { normalizeToolArguments } from '../../plugin/reasoning/turn-identity.js' + +export type ReplayPrefix = { + readonly reasoningText: string + readonly visibleText: string + readonly toolUses: readonly EmittedToolUse[] +} + +export type ReplayDivergenceChannel = 'reasoning' | 'text' | 'tool' | 'early_end' | 'none' + +export type ReplayMatchProgress = { + readonly matchedReasoningChars: number + readonly matchedVisibleChars: number + readonly matchedToolCount: number +} + +export type ReplayMatchResult = + | { readonly kind: 'withheld' } + | { readonly kind: 'release'; readonly chunks: readonly unknown[]; readonly caughtUp: boolean } + | { readonly kind: 'diverged'; readonly channel: Exclude } + +type ParsedChunk = { + readonly record: Record + readonly choices: readonly unknown[] + readonly first: Record + readonly delta: Record + readonly content: string | undefined + readonly reasoning: string | undefined + readonly toolCalls: readonly unknown[] + readonly terminal: boolean +} + +type StringMatch = + | { readonly kind: 'matched'; readonly offset: number; readonly suffix: string } + | { readonly kind: 'diverged'; readonly offset: number } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseChunk(chunk: unknown): ParsedChunk | undefined { + if (!isRecord(chunk)) return undefined + const choices = chunk['choices'] + if (!Array.isArray(choices)) return undefined + const first = choices[0] + if (!isRecord(first)) return undefined + const delta = first['delta'] + if (!isRecord(delta)) return undefined + const content = delta['content'] + const reasoning = delta['reasoning_content'] + const toolCalls = delta['tool_calls'] + const finishReason = first['finish_reason'] + return { + record: chunk, + choices, + first, + delta, + content: typeof content === 'string' ? content : undefined, + reasoning: typeof reasoning === 'string' ? reasoning : undefined, + toolCalls: Array.isArray(toolCalls) ? toolCalls : [], + terminal: finishReason !== null && finishReason !== undefined + } +} + +function matchString(expected: string, offset: number, value: string | undefined): StringMatch { + if (value === undefined || value.length === 0) return { kind: 'matched', offset, suffix: '' } + const remaining = expected.slice(offset) + const prefixLength = Math.min(remaining.length, value.length) + let matchedLength = 0 + while (matchedLength < prefixLength && value[matchedLength] === remaining[matchedLength]) { + matchedLength++ + } + if (matchedLength < prefixLength) { + return { kind: 'diverged', offset: offset + matchedLength } + } + return { + kind: 'matched', + offset: offset + prefixLength, + suffix: value.slice(prefixLength) + } +} + +function toolIndex(value: unknown): number | undefined { + if (!isRecord(value)) return undefined + const index = value['index'] + return typeof index === 'number' ? index : undefined +} + +function trimChunk( + parsed: ParsedChunk, + content: string, + reasoning: string, + toolCalls: readonly unknown[] +): unknown | undefined { + const hasSuffix = content.length > 0 || reasoning.length > 0 || toolCalls.length > 0 + if (!hasSuffix && !parsed.terminal) return undefined + + const delta: Record = {} + if (content.length > 0) delta['content'] = content + if (reasoning.length > 0) delta['reasoning_content'] = reasoning + if (toolCalls.length > 0) delta['tool_calls'] = toolCalls + return { + ...parsed.record, + choices: [{ ...parsed.first, delta }, ...parsed.choices.slice(1)] + } +} + +/** + * Matches one transformed replay against an already delivered three-channel prefix. + * Mutation is intentional: this object is a per-attempt accumulator and publication gate. + */ +export class ExactReplayMatcher { + private readonly prefix: ReplayPrefix + private readonly expectedTools: readonly EmittedToolUse[] + private readonly replayed = new EmittedOutputAccumulator() + private readonly toolPositions = new Map() + private readonly bufferedSuffix: unknown[] = [] + private reasoningOffset = 0 + private visibleOffset = 0 + private matchedTools = 0 + private caughtUp = false + private divergence: Exclude | undefined + + constructor(prefix: ReplayPrefix) { + this.prefix = prefix + this.expectedTools = prefix.toolUses.map((tool) => ({ + toolUseId: tool.toolUseId, + name: tool.name, + argumentsJson: normalizeToolArguments(tool.argumentsJson) + })) + } + + consume(chunk: unknown): ReplayMatchResult { + if (this.divergence) return { kind: 'diverged', channel: this.divergence } + if (this.caughtUp) return { kind: 'release', chunks: [chunk], caughtUp: false } + + const parsed = parseChunk(chunk) + if (!parsed) return { kind: 'withheld' } + const reasoning = matchString(this.prefix.reasoningText, this.reasoningOffset, parsed.reasoning) + this.reasoningOffset = reasoning.offset + if (reasoning.kind === 'diverged') return this.diverge('reasoning') + const visible = matchString(this.prefix.visibleText, this.visibleOffset, parsed.content) + this.visibleOffset = visible.offset + if (visible.kind === 'diverged') return this.diverge('text') + + for (const call of parsed.toolCalls) { + const index = toolIndex(call) + if (index !== undefined && !this.toolPositions.has(index)) { + this.toolPositions.set(index, this.toolPositions.size) + } + } + this.replayed.observeChunk(chunk) + const replayedTools = this.replayed.toolUses() + const toolDiverged = this.matchTools(replayedTools, parsed.terminal) + if (toolDiverged) return this.diverge('tool') + + const suffixTools = parsed.toolCalls.filter((call) => { + const index = toolIndex(call) + if (index === undefined) return false + const position = this.toolPositions.get(index) + return position !== undefined && position >= this.expectedTools.length + }) + const suffix = trimChunk(parsed, visible.suffix, reasoning.suffix, suffixTools) + if (!this.channelsCaughtUp()) { + if (parsed.terminal) return this.diverge('early_end') + if (suffix !== undefined) this.bufferedSuffix.push(suffix) + return { kind: 'withheld' } + } + + this.caughtUp = true + const chunks = [...this.bufferedSuffix] + this.bufferedSuffix.length = 0 + if (suffix !== undefined) chunks.push(suffix) + return { kind: 'release', chunks, caughtUp: true } + } + + progress(): ReplayMatchProgress { + return { + matchedReasoningChars: this.reasoningOffset, + matchedVisibleChars: this.visibleOffset, + matchedToolCount: this.matchedTools + } + } + + private matchTools(replayed: readonly EmittedToolUse[], terminal: boolean): boolean { + let matched = 0 + const compared = Math.min(replayed.length, this.expectedTools.length) + for (let index = 0; index < compared; index++) { + const actual = replayed[index] + const expected = this.expectedTools[index] + if (!actual || !expected) return true + if (actual.toolUseId !== expected.toolUseId || actual.name !== expected.name) return true + if (normalizeToolArguments(actual.argumentsJson) !== expected.argumentsJson) break + matched++ + } + this.matchedTools = matched + if (replayed.length > this.expectedTools.length && matched < this.expectedTools.length) { + return true + } + return terminal && matched < this.expectedTools.length + } + + private channelsCaughtUp(): boolean { + return ( + this.reasoningOffset === this.prefix.reasoningText.length && + this.visibleOffset === this.prefix.visibleText.length && + this.matchedTools === this.expectedTools.length + ) + } + + private diverge( + channel: Exclude + ): Extract { + this.divergence = channel + this.bufferedSuffix.length = 0 + return { kind: 'diverged', channel } + } +} diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 284dc74..9f04f70 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -311,7 +311,8 @@ export class RequestHandler { const liveRecoveryEnabled = sdkPrep.streaming && !this.config.stream_buffer_until_complete && - this.config.stream_recovery_mode === 'reasoning_restart' + (this.config.stream_recovery_mode === 'reasoning_restart' || + this.config.stream_recovery_mode === 'exact_replay') if (liveRecoveryEnabled) { const priorStreamFailures = streamFailureCount const availableStreamAttempts = this.config.stream_max_attempts - priorStreamFailures diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index 97efe09..e5517fc 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -360,7 +360,13 @@ export class ResponseHandler { }), close, complete: (completion) => - this.fireCompletion(lifecycle, reasoning, emitted, model, completion.recovered) + this.fireCompletion( + lifecycle, + reasoning, + emitted, + model, + completion.recoveryTier === 'reasoning_restart' + ) } } diff --git a/src/core/request/stream-recovery.ts b/src/core/request/stream-recovery.ts index 44bdde7..719ceca 100644 --- a/src/core/request/stream-recovery.ts +++ b/src/core/request/stream-recovery.ts @@ -7,9 +7,16 @@ * synthetic success before recovery starts. */ -export type StreamRecoveryMode = 'off' | 'reasoning_restart' +import { EmittedOutputAccumulator } from '../../plugin/reasoning/emitted-output.js' +import { + ExactReplayMatcher, + type ReplayDivergenceChannel, + type ReplayMatchProgress +} from './replay-matcher.js' -export type RecoveryTier = 'reasoning_restart' | 'none' +export type StreamRecoveryMode = 'off' | 'reasoning_restart' | 'exact_replay' + +export type RecoveryTier = 'reasoning_restart' | 'exact_replay' | 'none' export type RecoveryDecisionInput = { readonly mode: StreamRecoveryMode @@ -39,9 +46,16 @@ export type AttemptFactory = (attemptIndex: number) => Promise export type StreamRecoveryCompletion = { /** One-based index of the attempt that drained successfully. */ readonly attemptIndex: number + readonly recoveryTier: RecoveryTier readonly recovered: boolean } +export type ReplayAttemptTelemetry = ReplayMatchProgress & { + readonly divergenceChannel: ReplayDivergenceChannel + readonly replayOutcome: 'caught_up' | 'diverged' | 'failed' + readonly attempts: number +} + export type StreamRecoveryOptions = { readonly mode: StreamRecoveryMode readonly maxAttempts: number @@ -56,21 +70,29 @@ export type StreamRecoveryOptions = { readonly onComplete: (completion: StreamRecoveryCompletion) => void | Promise readonly onTerminal: () => void readonly onCancel?: (reason: unknown) => void + readonly onReplayAttempt?: (telemetry: ReplayAttemptTelemetry) => void } export function decideRecoveryTier(input: RecoveryDecisionInput): RecoveryTier { + const reasoningRestartEligible = + input.emitted.visibleChars === 0 && input.emitted.toolCount === 0 && !input.sawToolIntent switch (input.mode) { case 'off': return 'none' case 'reasoning_restart': - return input.emitted.visibleChars === 0 && - input.emitted.toolCount === 0 && - !input.sawToolIntent - ? 'reasoning_restart' - : 'none' + return reasoningRestartEligible ? 'reasoning_restart' : 'none' + case 'exact_replay': + if (reasoningRestartEligible) return 'reasoning_restart' + return input.emitted.visibleChars > 0 || input.emitted.toolCount > 0 ? 'exact_replay' : 'none' + default: + return assertNever(input.mode) } } +function assertNever(value: never): never { + throw new TypeError(`Unexpected stream recovery mode: ${String(value)}`) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -94,19 +116,29 @@ function errorFrom(failure: unknown): Error { : new TypeError('Stream attempt rejected with a non-Error value', { cause: failure }) } +class ReplayDivergenceError extends Error { + override readonly name = 'ReplayDivergenceError' + + constructor(readonly channel: Exclude) { + super(`Exact replay diverged in the ${channel} channel`) + } +} + export class StreamRecoveryCoordinator { readonly stream: ReadableStream private readonly options: StreamRecoveryOptions private activeAttempt: AttemptHandle | undefined private attemptIndex = 0 - private visibleChars = 0 - private toolCount = 0 + private readonly delivered = new EmittedOutputAccumulator() private sawToolIntent = false + private activeRecoveryTier: RecoveryTier = 'none' + private replayMatcher: ExactReplayMatcher | undefined private terminal = false private completionFired = false private abortListener: (() => void) | undefined private readonly pendingTerminalChunks: unknown[] = [] + private readonly pendingDeliveryChunks: unknown[] = [] constructor(options: StreamRecoveryOptions) { if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) { @@ -168,6 +200,12 @@ export class StreamRecoveryCoordinator { const attempt = this.activeAttempt if (!attempt) continue + const pending = this.pendingDeliveryChunks.shift() + if (pending !== undefined) { + if (this.publishChunk(pending, controller)) return + continue + } + let item: IteratorResult try { item = await attempt.chunks.next() @@ -179,17 +217,58 @@ export class StreamRecoveryCoordinator { if (this.terminal) return if (item.done) { + if (this.replayMatcher) { + this.reportReplayAttempt('diverged', 'early_end') + if ( + !(await this.recoverOrTerminate( + new ReplayDivergenceError('early_end'), + attempt, + controller + )) + ) { + return + } + continue + } await this.complete(controller) return } - if (this.pendingTerminalChunks.length > 0 || isTerminalChunk(item.value)) { - this.pendingTerminalChunks.push(item.value) + + const match = this.replayMatcher?.consume(item.value) + if (match?.kind === 'withheld') continue + if (match?.kind === 'diverged') { + this.reportReplayAttempt('diverged', match.channel) + if ( + !(await this.recoverOrTerminate( + new ReplayDivergenceError(match.channel), + attempt, + controller + )) + ) { + return + } continue } + if (match?.kind === 'release') { + if (match.caughtUp) this.reportReplayAttempt('caught_up', 'none') + this.pendingDeliveryChunks.push(...match.chunks) + continue + } + if (this.publishChunk(item.value, controller)) return + } + } - controller.enqueue(this.options.encodeChunk(item.value)) - return + private publishChunk( + chunk: unknown, + controller: ReadableStreamDefaultController + ): boolean { + if (this.pendingTerminalChunks.length > 0 || isTerminalChunk(chunk)) { + this.pendingTerminalChunks.push(chunk) + return false } + this.delivered.observeChunk(chunk) + controller.enqueue(this.options.encodeChunk(chunk)) + return true } private async openAttemptOrRecover( @@ -206,6 +285,9 @@ export class StreamRecoveryCoordinator { return true } catch (failure) { const error = failure instanceof Error ? failure : errorFrom(failure) + if (this.replayMatcher && !this.options.signal.aborted) { + this.reportReplayAttempt('failed', 'none') + } return this.recoverOrTerminate(error, undefined, controller) } } @@ -217,13 +299,20 @@ export class StreamRecoveryCoordinator { ): Promise { if (this.terminal) return false if (failedAttempt) this.mergeObservation(failedAttempt.observed()) + if (this.replayMatcher && !this.options.signal.aborted) { + this.reportReplayAttempt('failed', 'none') + } this.pendingTerminalChunks.length = 0 + this.pendingDeliveryChunks.length = 0 await this.closeActiveAttempt() if (this.terminal) return false const tier = decideRecoveryTier({ mode: this.options.mode, - emitted: { visibleChars: this.visibleChars, toolCount: this.toolCount }, + emitted: { + visibleChars: this.delivered.visibleText.length, + toolCount: this.delivered.toolUses().length + }, sawToolIntent: this.sawToolIntent }) if (tier === 'none' || this.attemptIndex >= this.options.maxAttempts) { @@ -232,16 +321,37 @@ export class StreamRecoveryCoordinator { return false } + this.activeRecoveryTier = tier + if (tier === 'exact_replay') { + this.replayMatcher = new ExactReplayMatcher({ + reasoningText: this.delivered.reasoningText, + visibleText: this.delivered.visibleText, + toolUses: this.delivered.toolUses() + }) + } await this.options.delayFn(this.attemptIndex, this.options.signal) return !this.terminal } private mergeObservation(observation: AttemptObservation): void { - this.visibleChars += observation.emitted.visibleChars - this.toolCount += observation.emitted.toolCount this.sawToolIntent ||= observation.sawToolIntent } + private reportReplayAttempt( + replayOutcome: ReplayAttemptTelemetry['replayOutcome'], + divergenceChannel: ReplayDivergenceChannel + ): void { + const matcher = this.replayMatcher + if (!matcher) return + this.options.onReplayAttempt?.({ + ...matcher.progress(), + divergenceChannel, + replayOutcome, + attempts: this.attemptIndex + }) + this.replayMatcher = undefined + } + private async complete(controller: ReadableStreamDefaultController): Promise { const succeededAttempt = this.attemptIndex await this.closeActiveAttempt() @@ -251,7 +361,11 @@ export class StreamRecoveryCoordinator { this.completionFired = true await this.options.onComplete({ attemptIndex: succeededAttempt, - recovered: succeededAttempt > 1 + recoveryTier: this.activeRecoveryTier, + // Tier A concatenates unrelated reasoning attempts, so its envelope cannot + // describe the delivered output. Exact replay matched every delivered channel; + // prefix + suffix equals the successful replay itself, making its envelope safe. + recovered: this.activeRecoveryTier === 'reasoning_restart' }) } if (this.terminal) return diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index 1e07df0..70f2737 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -19,10 +19,12 @@ export type Effort = z.infer * - off: no recovery; behavior is byte-for-byte identical to pre-recovery builds * - reasoning_restart: restart the turn from accumulated reasoning instead of * replaying already-emitted content + * - exact_replay: includes reasoning_restart, then uses exact three-channel shadow + * replay when visible text or tools have already been delivered * The literal strings must stay identical to `StreamRecoveryMode` in * src/core/request/stream-recovery.ts (the coordinator consumes this value). */ -export const StreamRecoveryModeSchema = z.enum(['off', 'reasoning_restart']) +export const StreamRecoveryModeSchema = z.enum(['off', 'reasoning_restart', 'exact_replay']) export type StreamRecoveryMode = z.infer export const RegionSchema = z.enum([ From 04ca20fe3ac6929cef4e48910ac60669e5649ae5 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 14:46:24 +0800 Subject: [PATCH 10/21] =?UTF-8?q?test(request):=20=E8=A1=A5=E9=BD=90=20Tie?= =?UTF-8?q?r=20B=20=E7=B2=BE=E7=A1=AE=E9=87=8D=E6=94=BE=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 T2.5 补齐 Phase 2 Tier B 精确影子重放的验收覆盖,生产代码零改动。 - replay-matcher:新增 chunk 切分无关性(逐字符、单巨块、CJK、代理对跨块、 多字节中途失配)与 tool 通道差异(id 变、name 变、args 流中暂扣至 terminal、 args 分块重组追平、前缀未完成时多出工具、前缀完成后新工具释放)两组用例。 - coordinator:多字节重切分追平、tool 身份差异连续消耗预算、args 差异暂扣、 预算耗尽只 map 一次终端错误、暂扣态 abort 无遥测且 onTerminal 恰一次。 - request-handler:新增 §9 Tier B 故障注入矩阵 9 例,覆盖 text 中途断的 SSE 字节级零泄漏、预算耗尽保持 UpstreamUnexpectedError + emittedOutput、 语义截断经 Tier B 追平、reasoning-only 走 Tier A 零 matcher 介入、 stream_max_attempts 与 max_request_iterations 双预算、每次 replay 一条遥测, 并钉住 tool-intent-only 与完整工具调用中断的保守 'none' 路由。 - redaction:新增 exact replay 遥测只含匹配量、不含 reasoning/正文/影子文本。 --- src/__tests__/reasoning-log-redaction.test.ts | 42 ++ src/__tests__/replay-matcher.test.ts | 197 ++++++++ src/__tests__/request-handler.test.ts | 474 +++++++++++++++++- .../stream-recovery-exact-replay.test.ts | 196 +++++++- 4 files changed, 888 insertions(+), 21 deletions(-) diff --git a/src/__tests__/reasoning-log-redaction.test.ts b/src/__tests__/reasoning-log-redaction.test.ts index 2d0403a..eaab087 100644 --- a/src/__tests__/reasoning-log-redaction.test.ts +++ b/src/__tests__/reasoning-log-redaction.test.ts @@ -471,6 +471,48 @@ describe('§6.8 redaction — stream observability fields', () => { expectNoLeak(text) }) + test('exact replay telemetry carries matched volumes only, never replay text', async () => { + const SHADOW_REPLY = `${STREAMED_REPLY.slice(0, -3)}LOG` + const MATCHED = STREAMED_REPLY.length - 3 + let sends = 0 + const handler = wireHandler( + streamingPrep(), + async () => { + sends++ + const failing = sends === 1 + return { + generateAssistantResponseResponse: (async function* () { + yield { reasoningContentEvent: { text: REASONING } } + yield { assistantResponseEvent: { content: failing ? STREAMED_REPLY : SHADOW_REPLY } } + if (failing) throw new Error('reset after visible output') + })() + } + }, + { stream_max_attempts: 2, stream_recovery_mode: 'exact_replay' } + ) + ;(handler as unknown as { sleep: () => Promise }).sleep = async () => {} + + await drain( + await handler.handle( + KIRO_URL, + { body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'go' }] }) }, + noToast + ) + ) + + expect(sends).toBe(2) + const text = allLogText() + expect(text).toContain('Kiro exact replay attempt finished') + expect(text).toContain('"replayOutcome":"diverged"') + expect(text).toContain('"divergenceChannel":"text"') + expect(text).toContain(`"matchedReasoningChars":${REASONING.length}`) + expect(text).toContain(`"matchedVisibleChars":${MATCHED}`) + expect(text).not.toContain(REASONING) + expect(text).not.toContain(STREAMED_REPLY) + expect(text).not.toContain(SHADOW_REPLY) + expectNoLeak(text) + }) + test('the missing-completion-metadata marker carries volume only', async () => { const handler = wireHandler( streamingPrep(), diff --git a/src/__tests__/replay-matcher.test.ts b/src/__tests__/replay-matcher.test.ts index 2440eaf..b1b92d0 100644 --- a/src/__tests__/replay-matcher.test.ts +++ b/src/__tests__/replay-matcher.test.ts @@ -135,3 +135,200 @@ describe('ExactReplayMatcher', () => { expect(matcher.progress().matchedVisibleChars).toBe(5) }) }) + +describe('ExactReplayMatcher chunk-boundary independence', () => { + test('catches up on one char per chunk and releases only the trailing suffix chars', () => { + // Given + const matcher = new ExactReplayMatcher(TEXT_PREFIX) + const replayed = 'hello world!?' + + // When + const results = [...replayed].map((character) => matcher.consume(chunk({ content: character }))) + + // Then + expect(results.slice(0, 10).every((result) => result.kind === 'withheld')).toBe(true) + expect(released(results[10]!)).toEqual([]) + expect(contentOf(released(results[11]!)[0], 'content')).toBe('!') + expect(contentOf(released(results[12]!)[0], 'content')).toBe('?') + expect(matcher.progress().matchedVisibleChars).toBe(11) + }) + + test('catches up when the whole reply arrives as one giant chunk', () => { + // Given + const matcher = new ExactReplayMatcher(TEXT_PREFIX) + + // When + const result = matcher.consume(chunk({ content: 'hello world and then some more' })) + + // Then + const suffix = released(result) + expect(suffix).toHaveLength(1) + expect(contentOf(suffix[0], 'content')).toBe(' and then some more') + expect(matcher.progress().matchedVisibleChars).toBe(11) + }) + + test('matches CJK text split at boundaries the first attempt never used', () => { + // Given + const matcher = new ExactReplayMatcher({ + reasoningText: '', + visibleText: '你好,世界', + toolUses: [] + }) + + // When + const first = matcher.consume(chunk({ content: '你好' })) + const second = matcher.consume(chunk({ content: ',世' })) + const third = matcher.consume(chunk({ content: '界!' })) + + // Then + expect([first, second].every((result) => result.kind === 'withheld')).toBe(true) + expect(contentOf(released(third)[0], 'content')).toBe('!') + expect(matcher.progress().matchedVisibleChars).toBe(5) + }) + + test('matches an emoji whose surrogate pair is split across two replay chunks', () => { + // Given: the matcher compares JS string chars, i.e. UTF-16 code units, so a + // lone high surrogate is a legal intermediate state rather than a mismatch. + const emoji = '😀' + expect(emoji.length).toBe(2) + const matcher = new ExactReplayMatcher({ + reasoningText: '', + visibleText: `ok${emoji}`, + toolUses: [] + }) + + // When + const head = matcher.consume(chunk({ content: `ok${emoji[0]}` })) + const tail = matcher.consume(chunk({ content: `${emoji[1]}done` })) + + // Then + expect(head).toEqual({ kind: 'withheld' }) + expect(matcher.progress().matchedVisibleChars).toBe(4) + expect(contentOf(released(tail)[0], 'content')).toBe('done') + }) + + test('reports a mismatch inside a multibyte character as a text divergence', () => { + // Given + const matcher = new ExactReplayMatcher({ + reasoningText: '', + visibleText: '你好,世界', + toolUses: [] + }) + + // When + const result = matcher.consume(chunk({ content: '你好,宇宙' })) + + // Then + expect(result).toEqual({ kind: 'diverged', channel: 'text' }) + expect(matcher.progress().matchedVisibleChars).toBe(3) + }) +}) + +describe('ExactReplayMatcher tool channel divergence', () => { + const ONE_TOOL: ReplayPrefix = { + reasoningText: '', + visibleText: '', + toolUses: [{ toolUseId: 'tool-1', name: 'read', argumentsJson: '{"path":"/a"}' }] + } + + function call( + id: string, + name: string | undefined, + argumentsJson: string | undefined, + index = 0 + ): unknown { + const fn: Record = {} + if (name !== undefined) fn['name'] = name + if (argumentsJson !== undefined) fn['arguments'] = argumentsJson + return { index, id, function: fn } + } + + test('rejects a replayed tool whose id changed', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When + const result = matcher.consume(chunk({ tool_calls: [call('tool-9', 'read', '{"path":"/a"}')] })) + + // Then + expect(result).toEqual({ kind: 'diverged', channel: 'tool' }) + expect(matcher.progress().matchedToolCount).toBe(0) + }) + + test('rejects a replayed tool whose name changed', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When + const result = matcher.consume( + chunk({ tool_calls: [call('tool-1', 'write', '{"path":"/a"}')] }) + ) + + // Then + expect(result).toEqual({ kind: 'diverged', channel: 'tool' }) + }) + + test('withholds a still-streaming argument prefix and only diverges at the terminal chunk', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When: an argument delta that is a legal prefix of nothing yet cannot be + // judged mid-stream, so the matcher waits instead of guessing. + const partial = matcher.consume(chunk({ tool_calls: [call('tool-1', 'read', '{"path":')] })) + const wrong = matcher.consume(chunk({ tool_calls: [call('tool-1', undefined, '"/b"}')] })) + const terminal = matcher.consume(chunk({}, 'tool_calls')) + + // Then + expect(partial).toEqual({ kind: 'withheld' }) + expect(wrong).toEqual({ kind: 'withheld' }) + expect(terminal).toEqual({ kind: 'diverged', channel: 'tool' }) + expect(matcher.progress().matchedToolCount).toBe(0) + }) + + test('catches up once a chunked argument stream reassembles the same normalized JSON', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When + const opening = matcher.consume(chunk({ tool_calls: [call('tool-1', 'read', '{ "path"')] })) + const closing = matcher.consume(chunk({ tool_calls: [call('tool-1', undefined, ': "/a" }')] })) + + // Then + expect(opening).toEqual({ kind: 'withheld' }) + expect(closing).toEqual({ kind: 'release', chunks: [], caughtUp: true }) + expect(matcher.progress().matchedToolCount).toBe(1) + }) + + test('rejects an extra replayed tool that appears before the known prefix completes', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When + const result = matcher.consume( + chunk({ + tool_calls: [call('tool-1', 'read', '{"path":'), call('tool-2', 'write', '{}', 1)] + }) + ) + + // Then + expect(result).toEqual({ kind: 'diverged', channel: 'tool' }) + }) + + test('releases a genuinely new tool call once the delivered tool prefix matched', () => { + // Given + const matcher = new ExactReplayMatcher(ONE_TOOL) + + // When + const result = matcher.consume( + chunk({ + tool_calls: [call('tool-1', 'read', '{"path":"/a"}'), call('tool-2', 'write', '{}', 1)] + }) + ) + + // Then + const suffix = released(result) + expect(suffix).toHaveLength(1) + expect(JSON.stringify(suffix[0])).toContain('tool-2') + expect(JSON.stringify(suffix[0])).not.toContain('tool-1') + }) +}) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index bdc0ec2..0ca0a4d 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -245,6 +245,41 @@ function streamedText(body: string): string { .join('') } +function sseFrames(body: string): Array> { + const raw = body.split('\n\n') + expect(raw.at(-1)).toBe('') + return raw.slice(0, -1).map((frame) => { + expect(frame.startsWith('data: ')).toBe(true) + return JSON.parse(frame.slice('data: '.length)) as Record + }) +} + +function joinedDelta( + frames: Array>, + field: 'content' | 'reasoning_content' +): string { + return frames.map((frame) => frame.choices?.[0]?.delta?.[field] ?? '').join('') +} + +function terminalFrames(frames: Array>): Array> { + return frames.filter((frame) => { + const finish = frame.choices?.[0]?.finish_reason + return finish !== null && finish !== undefined + }) +} + +function records( + spy: ReturnType['warn'], + message: string +): Array> { + return spy.mock.calls + .filter((call) => call[0] === message) + .map((call) => call[1] as Record) +} + +const STREAM_FAILURE_LOG = 'Kiro SDK event stream iteration failed' +const REPLAY_TELEMETRY_LOG = 'Kiro exact replay attempt finished' + function installImmediateStreamBackoff(handler: RequestHandler): void { const internals = handler as unknown as { streamRetryRandom: () => number @@ -2679,26 +2714,6 @@ describe('RequestHandler.handle — API request logging', () => { }) describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', () => { - function sseFrames(body: string): Array> { - const raw = body.split('\n\n') - expect(raw.at(-1)).toBe('') - return raw.slice(0, -1).map((frame) => { - expect(frame.startsWith('data: ')).toBe(true) - return JSON.parse(frame.slice('data: '.length)) as Record - }) - } - - function records( - spy: ReturnType['warn'], - message: string - ): Array> { - return spy.mock.calls - .filter((call) => call[0] === message) - .map((call) => call[1] as Record) - } - - const STREAM_FAILURE_LOG = 'Kiro SDK event stream iteration failed' - test('row 1: a pre-output failure in recovery mode stays on the legacy retry path', async () => { const acc = makeAccount({ id: 'A' }) const logs = captureLogger() @@ -3041,6 +3056,425 @@ describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', }) }) +describe('RequestHandler.handle — §9 Tier B exact-replay fault-injection matrix', () => { + // The transformer withholds the trailing ``-tag-sized window of a + // content event until more text arrives, so a bare content event that breaks + // mid-stream delivers only its leading characters. A native reasoning event + // ahead of the text closes that window, which makes the delivered prefix + // exactly the injected strings and keeps these rows about routing, not about + // buffer arithmetic. + const THOUGHT = 'thought' + const DELIVERED = 'hello world' + const SHADOW_LEAK = 'MUST-NOT-LEAK' + const DIVERGENT = `hello wurld ${SHADOW_LEAK}` + const MATCHED_UNTIL = 'hello w'.length + + function reasoningThenText(content: string, ...trailing: unknown[]): unknown[] { + return [ + { reasoningContentEvent: { text: THOUGHT } }, + { assistantResponseEvent: { content } }, + ...trailing + ] + } + + function expectNoShadow(body: string): void { + expect(body).not.toContain(SHADOW_LEAK) + expect(body).not.toContain('wurld') + } + + test('row text mid-break: a divergent replay is attempted and leaks no byte downstream', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream(reasoningThenText(DELIVERED), new Error('first reset')), + sdkStream(reasoningThenText(DIVERGENT)), + sdkStream( + reasoningThenText(`${DELIVERED}!`, { + metadataEvent: { tokenUsage: { outputTokens: 2 } } + }) + ) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + const frames = sseFrames(body) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(3) + expect(joinedDelta(frames, 'reasoning_content')).toBe(THOUGHT) + expect(joinedDelta(frames, 'content')).toBe(`${DELIVERED}!`) + expectNoShadow(body) + expect(terminalFrames(frames)).toHaveLength(1) + expect( + records(logs.log, REPLAY_TELEMETRY_LOG).map((entry) => [ + entry['replayOutcome'], + entry['divergenceChannel'], + entry['matchedReasoningChars'], + entry['matchedVisibleChars'] + ]) + ).toEqual([ + ['diverged', 'text', THOUGHT.length, MATCHED_UNTIL], + ['caught_up', 'none', THOUGHT.length, DELIVERED.length] + ]) + } finally { + logs.restore() + } + }) + + test('row budget exhausted: the terminal error keeps its shape and the shadow stays withheld', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream(reasoningThenText(DELIVERED), new Error('first reset')), + sdkStream(reasoningThenText(DIVERGENT)) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay', + streamMaxAttempts: 2 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const stream = response.body + if (!stream) throw new Error('expected a streaming response body') + const reader = stream.getReader() + let delivered = '' + const draining = (async () => { + while (true) { + const item = await reader.read() + if (item.done) return + delivered += new TextDecoder().decode(item.value) + } + })() + + await expect(draining).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + const frames = sseFrames(delivered) + expect(joinedDelta(frames, 'content')).toBe(DELIVERED) + expect(joinedDelta(frames, 'reasoning_content')).toBe(THOUGHT) + expectNoShadow(delivered) + expect(terminalFrames(frames)).toHaveLength(0) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([ + expect.objectContaining({ + divergenceChannel: 'text', + replayOutcome: 'diverged', + matchedVisibleChars: MATCHED_UNTIL + }) + ]) + expect(records(logs.error, STREAM_FAILURE_LOG)[0]).toMatchObject({ + outcome: 'terminated_after_output', + emittedOutput: true + }) + } finally { + logs.restore() + } + }) + + test('row completed tool call: a mid-stream break still delivers no tool prefix to match', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: THOUGHT } }, + { + toolUseEvent: { + toolUseId: 'tool-1', + name: 'read_file', + input: '{"path":"/a"}', + stop: true + } + } + ], + new Error('reset after a completed tool call') + ), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + // The transformer accumulates tool calls and emits them only after the + // event loop drains, so a break can never leave a delivered tool prefix: + // `sawToolIntent` is set while `emittedToolCount` stays 0, which routes to + // 'none'. The matcher's tool channel is therefore exercised at the + // coordinator level, not through this path. + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(records(logs.error, STREAM_FAILURE_LOG)[0]).toMatchObject({ + outcome: 'terminated_after_output', + emittedToolCount: 0, + sawToolIntent: true + }) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([]) + } finally { + logs.restore() + } + }) + + test('row raw tool intent: an intent-only break stays terminal instead of replaying', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [ + { reasoningContentEvent: { text: 'reasoning before tool intent' } }, + { toolUseEvent: { name: 'read_file', toolUseId: 'tool-1', input: '{"path":"/a' } } + ], + new Error('tool stream failed') + ), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + // `sawToolIntent` disqualifies Tier A while zero visible text and zero + // emitted tools also disqualify Tier B, so `decideRecoveryTier` returns + // 'none'. This is deliberately conservative: an unfinished tool intent + // leaves nothing for a replay to match against. Pinned so a routing + // change cannot pass silently; loosening it is a Phase 3 question. + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([]) + } finally { + logs.restore() + } + }) + + test('row clean EOF: a truncation after delivered text routes into exact replay', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream(reasoningThenText(DELIVERED)), + sdkStream( + reasoningThenText(`${DELIVERED} continued`, { + metadataEvent: { tokenUsage: { outputTokens: 3 } } + }) + ) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(joinedDelta(frames, 'content')).toBe(`${DELIVERED} continued`) + expect(joinedDelta(frames, 'reasoning_content')).toBe(THOUGHT) + expect(terminalFrames(frames)).toHaveLength(1) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([ + expect.objectContaining({ + matchedReasoningChars: THOUGHT.length, + matchedVisibleChars: DELIVERED.length, + divergenceChannel: 'none', + replayOutcome: 'caught_up' + }) + ]) + } finally { + logs.restore() + } + }) + + test('row mode ordering: a reasoning-only break under exact_replay never builds a matcher', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream( + [{ reasoningContentEvent: { text: 'partial reasoning' } }], + new Error('reasoning reset') + ), + sdkStream([ + { reasoningContentEvent: { text: 'restarted reasoning' } }, + { assistantResponseEvent: { content: 'final answer' } }, + { metadataEvent: { tokenUsage: { outputTokens: 2 } } } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const frames = sseFrames(await response.text()) + + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + expect(joinedDelta(frames, 'reasoning_content')).toBe('partial reasoningrestarted reasoning') + expect(joinedDelta(frames, 'content')).toBe('final answer') + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([]) + } finally { + logs.restore() + } + }) + + test('exact replay honors stream_max_attempts and logs one telemetry record per replay', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream(reasoningThenText(DELIVERED), new Error('first reset')), + sdkStream(reasoningThenText(DIVERGENT)), + sdkStream(reasoningThenText(DIVERGENT)), + sdkStream(reasoningThenText(`${DELIVERED}!`)) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay', + streamMaxAttempts: 3 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(3) + const telemetry = records(logs.log, REPLAY_TELEMETRY_LOG) + expect(telemetry.map((entry) => entry['attempts'])).toEqual([2, 3]) + expect(telemetry.map((entry) => entry['quotaNote'])).toEqual([ + 'each exact replay attempt consumes one real SDK send', + 'each exact replay attempt consumes one real SDK send' + ]) + } finally { + logs.restore() + } + }) + + test('exact replay stops at the RetryStrategy iteration budget before stream_max_attempts', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream(reasoningThenText(DELIVERED), new Error('first reset')), + sdkStream(reasoningThenText(DIVERGENT)), + sdkStream(reasoningThenText(`${DELIVERED}!`)) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay', + streamMaxAttempts: 5, + maxRequestIterations: 2 + }) + installImmediateStreamBackoff(handler) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + + await expect(response.text()).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + emittedOutput: true + }) + expect(fakes.sdkSend).toHaveBeenCalledTimes(2) + }) + + test('caller abort while a shadow replay is withheld releases the queue for the next request', async () => { + const acc = makeAccount({ id: 'A' }) + const logs = captureLogger() + try { + const { handler } = buildHandler({ + selectResults: [acc, acc], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'exact_replay' + }) + installImmediateStreamBackoff(handler) + const internals = handler as unknown as { + makeSdkClient: () => { send: () => Promise } + } + let sendCalls = 0 + const shadowWithheld = Promise.withResolvers() + internals.makeSdkClient = () => ({ + send: async () => { + sendCalls++ + if (sendCalls === 1) { + return sdkStream(reasoningThenText(DELIVERED), new Error('late reset')) + } + if (sendCalls === 2) { + return { + generateAssistantResponseResponse: (async function* () { + yield { reasoningContentEvent: { text: THOUGHT } } + shadowWithheld.resolve() + await new Promise(() => {}) + })() + } + } + return sdkStream([ + { assistantResponseEvent: { content: 'later request succeeds' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + } + }) + + const controller = new AbortController() + const response = await handler.handle( + KIRO_URL, + { body: JSON.stringify({}), signal: controller.signal }, + noToast + ) + const reading = response.text() + + await shadowWithheld.promise + controller.abort(new DOMException('cancelled while withholding', 'AbortError')) + + await expect(reading).rejects.toMatchObject({ name: 'AbortError' }) + expect(sendCalls).toBe(2) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([]) + const next = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + expect(streamedText(await next.text())).toBe('later request succeeds') + expect(sendCalls).toBe(3) + } finally { + logs.restore() + } + }) +}) + describe('RequestHandler.handle — circuit breaker', () => { test('exceeding max_request_iterations throws the retry-strategy error', async () => { globalThis.setTimeout = ((fn: any) => { diff --git a/src/__tests__/stream-recovery-exact-replay.test.ts b/src/__tests__/stream-recovery-exact-replay.test.ts index bd64217..1719027 100644 --- a/src/__tests__/stream-recovery-exact-replay.test.ts +++ b/src/__tests__/stream-recovery-exact-replay.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'bun:test' -import { decideRecoveryTier } from '../core/request/stream-recovery.js' +import { decideRecoveryTier, type AttemptHandle } from '../core/request/stream-recovery.js' import { + EMPTY_OBSERVATION, TestStreamFailure, chunk, collect, createHarness, + expectRejection, makeAttempt } from './stream-recovery.fixture.js' @@ -219,6 +221,198 @@ describe('StreamRecoveryCoordinator exact replay', () => { expect(harness.replayTelemetry[0]?.divergenceChannel).toBe('early_end') }) + test('matches a multibyte prefix re-split at different boundaries', async () => { + // Given + const first = makeAttempt({ + output: [chunk('first-1', { content: '你好' }), chunk('first-2', { content: ',世界' })], + observation: { emitted: { visibleChars: 5, toolCount: 0 }, sawToolIntent: false }, + failure: new TestStreamFailure('failed after multibyte prefix') + }) + const replay = makeAttempt({ + output: [ + chunk('shadow-1', { content: '你' }), + chunk('shadow-2', { content: '好,世' }), + chunk('suffix', { content: '界!' }), + chunk('finish', {}, 'stop') + ] + }) + const harness = createHarness([first, replay], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-1', 'first-2', 'suffix', 'finish']) + expect(harness.replayTelemetry).toEqual([ + { + matchedReasoningChars: 0, + matchedVisibleChars: 5, + matchedToolCount: 0, + divergenceChannel: 'none', + replayOutcome: 'caught_up', + attempts: 2 + } + ]) + }) + + test('consumes one budget slot per tool identity divergence without leaking a shadow tool', async () => { + // Given + const delivered = [toolCall(0, 'tool-1', 'read', '{"path":"/a"}')] + const first = makeAttempt({ + output: [chunk('first-tools', { tool_calls: delivered })], + observation: { emitted: { visibleChars: 0, toolCount: 1 }, sawToolIntent: true }, + failure: new TestStreamFailure('failed after one tool') + }) + const renamedId = makeAttempt({ + output: [chunk('leaked-id', { tool_calls: [toolCall(0, 'tool-9', 'read', '{"path":"/a"}')] })] + }) + const renamedName = makeAttempt({ + output: [ + chunk('leaked-name', { tool_calls: [toolCall(0, 'tool-1', 'write', '{"path":"/a"}')] }) + ] + }) + const recovered = makeAttempt({ + output: [chunk('matched-tools', { tool_calls: delivered }), chunk('finish', {}, 'tool_calls')] + }) + const harness = createHarness([first, renamedId, renamedName, recovered], { + mode: 'exact_replay' + }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-tools', 'finish']) + expect(harness.requestedAttempts).toEqual([1, 2, 3, 4]) + expect( + harness.replayTelemetry.map(({ replayOutcome, divergenceChannel, matchedToolCount }) => ({ + replayOutcome, + divergenceChannel, + matchedToolCount + })) + ).toEqual([ + { replayOutcome: 'diverged', divergenceChannel: 'tool', matchedToolCount: 0 }, + { replayOutcome: 'diverged', divergenceChannel: 'tool', matchedToolCount: 0 }, + { replayOutcome: 'caught_up', divergenceChannel: 'none', matchedToolCount: 1 } + ]) + }) + + test('holds a mismatched tool argument stream back until its terminal chunk diverges', async () => { + // Given + const first = makeAttempt({ + output: [ + chunk('first-tools', { tool_calls: [toolCall(0, 'tool-1', 'read', '{"path":"/a"}')] }) + ], + observation: { emitted: { visibleChars: 0, toolCount: 1 }, sawToolIntent: true }, + failure: new TestStreamFailure('failed after one tool') + }) + const wrongArguments = makeAttempt({ + output: [ + chunk('leaked-open', { tool_calls: [toolCall(0, 'tool-1', 'read', '{"path":')] }), + chunk('leaked-close', { tool_calls: [toolCall(0, 'tool-1', 'read', '"/b"}')] }), + chunk('leaked-finish', {}, 'tool_calls') + ] + }) + const recovered = makeAttempt({ + output: [ + chunk('matched-tools', { tool_calls: [toolCall(0, 'tool-1', 'read', '{"path":"/a"}')] }), + chunk('finish', {}, 'tool_calls') + ] + }) + const harness = createHarness([first, wrongArguments, recovered], { mode: 'exact_replay' }) + + // When + const labels = await collect(harness.coordinator.stream) + + // Then + expect(labels).toEqual(['first-tools', 'finish']) + expect(harness.replayTelemetry[0]).toEqual({ + matchedReasoningChars: 0, + matchedVisibleChars: 0, + matchedToolCount: 0, + divergenceChannel: 'tool', + replayOutcome: 'diverged', + attempts: 2 + }) + }) + + test('spends every remaining budget slot on divergent replays and then maps one terminal error', async () => { + // Given + const mapped = new TestStreamFailure('exact replay budget exhausted') + const first = makeAttempt({ + output: [chunk('first', { content: 'hello world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('first failure') + }) + const divergent = (label: string): AttemptHandle => + makeAttempt({ output: [chunk(label, { content: 'hello wurld' })] }) + const harness = createHarness([first, divergent('leak-2'), divergent('leak-3')], { + mode: 'exact_replay', + maxAttempts: 3, + mapError: () => mapped + }) + const reader = harness.coordinator.stream.getReader() + + // When + expect(new TextDecoder().decode((await reader.read()).value)).toBe('first') + + // Then + await expectRejection(reader.read(), mapped) + expect(harness.requestedAttempts).toEqual([1, 2, 3]) + expect(harness.replayTelemetry).toHaveLength(2) + expect(harness.replayTelemetry.map((entry) => entry.attempts)).toEqual([2, 3]) + expect(harness.completions).toEqual([]) + expect(harness.terminalCalls()).toBe(1) + }) + + test('abort while a shadow replay is withheld terminates once and reports no divergence', async () => { + // Given + const controller = new AbortController() + const withheld = Promise.withResolvers() + let closeCalls = 0 + let reads = 0 + const stalledReplay: AttemptHandle = { + chunks: { + next: async () => { + reads++ + if (reads === 1) return { done: false, value: chunk('shadow', { content: 'hello' }) } + withheld.resolve() + return new Promise>(() => {}) + } + }, + observed: () => EMPTY_OBSERVATION, + close: async () => { + closeCalls++ + } + } + const first = makeAttempt({ + output: [chunk('first', { content: 'hello world' })], + observation: VISIBLE_OBSERVATION, + failure: new TestStreamFailure('first failure') + }) + const harness = createHarness([first, stalledReplay], { + mode: 'exact_replay', + maxAttempts: 3, + signal: controller.signal + }) + const reader = harness.coordinator.stream.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toBe('first') + const pendingRead = reader.read() + await withheld.promise + + // When + const reason = new DOMException('cancelled while withholding', 'AbortError') + controller.abort(reason) + + // Then + await expectRejection(pendingRead, reason) + await Promise.resolve() + expect(closeCalls).toBe(1) + expect(harness.replayTelemetry).toEqual([]) + expect(harness.requestedAttempts).toEqual([1, 2]) + expect(harness.terminalCalls()).toBe(1) + }) + test('reports a replay stream failure before catch-up without leaking its shadow bytes', async () => { // Given const first = makeAttempt({ From 1722897360ae5c94046aa3d8c4e012609bc42035 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 15:35:26 +0800 Subject: [PATCH 11/21] =?UTF-8?q?fix(request):=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E6=94=B9=E4=B8=BA=E6=9C=AA=E9=97=AD=E5=90=88?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=84=8F=E5=9B=BE=E5=88=A4=E5=AE=9A=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=81=A2=E5=A4=8D=E6=A1=A3=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 跟踪 raw 与 dialect 工具意图的闭合状态\n- 保持 metadata-less clean EOF 在全部档位成功\n- 修正非流式输入 token 字段并补齐 F1/F3 回归 --- src/__tests__/request-handler.test.ts | 183 +++++++++++++++--- src/__tests__/response-handler-sdk.test.ts | 6 +- src/__tests__/stream-observability.test.ts | 5 + src/__tests__/stream-observer-closure.test.ts | 70 +++++++ src/core/request/response-handler.ts | 25 +-- .../streaming/sdk-stream-transformer.ts | 3 +- src/plugin/streaming/stream-observer.ts | 28 ++- 7 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 src/__tests__/stream-observer-closure.test.ts diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index 0ca0a4d..41eb786 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1262,17 +1262,16 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { expect(fakes.sdkSend).toHaveBeenCalledTimes(1) }) - test('clean EOF after reasoning is a recoverable semantic truncation in recovery mode', async () => { - const acc = makeAccount({ id: 'A' }) + test('healthy reasoning and text without metadata completes without recovery', async () => { + const acc = makeAccount({ id: 'A', failCount: 2, unhealthyReason: 'transient' }) const { handler, fakes } = buildHandler({ selectResults: [acc], sdkResults: [ - sdkStream([{ reasoningContentEvent: { text: 'truncated reasoning' } }]), sdkStream([ - { reasoningContentEvent: { text: 'replacement reasoning' } }, - { assistantResponseEvent: { content: 'replacement answer' } }, - { metadataEvent: { tokenUsage: { outputTokens: 2, totalTokens: 2 } } } - ]) + { reasoningContentEvent: { text: 'complete reasoning' } }, + { assistantResponseEvent: { content: 'complete answer' } } + ]), + sdkStream([{ assistantResponseEvent: { content: 'must not be sent' } }]) ], streaming: true, useRealResponseHandler: true, @@ -1283,10 +1282,12 @@ describe('RequestHandler.handle — SDK event-stream retry boundary', () => { const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) const body = await response.text() - expect(fakes.sdkSend).toHaveBeenCalledTimes(2) - expect(body).toContain('truncated reasoning') - expect(body).toContain('replacement answer') + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(body).toContain('complete reasoning') + expect(streamedText(body)).toBe('complete answer') expect(body.split('"finish_reason":"stop"')).toHaveLength(2) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect(acc.failCount).toBe(0) }) test('reasoning restart honors stream_max_attempts across initial and recovery sends', async () => { @@ -1596,6 +1597,18 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { { metadataEvent: { tokenUsage: { outputTokens: 1 } } } ] + const incompleteSignedToolEvents = (label: string): unknown[] => [ + { reasoningContentEvent: { text: `reasoning-${label}` } }, + { reasoningContentEvent: { signature: `signature-${label}` } }, + { + toolUseEvent: { + name: 'read_file', + toolUseId: `tool-${label}`, + input: `{"path":"/${label}` + } + } + ] + const lookupSignedTool = (label: string) => reasoningCorrelationCache.lookup({ reasoningText: `reasoning-${label}`, @@ -1610,6 +1623,88 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { effectiveModel: 'claude-sonnet-4-5' }) + const lookupIncompleteSignedTool = (label: string) => + reasoningCorrelationCache.lookup({ + reasoningText: `reasoning-${label}`, + visibleText: '', + toolUses: [ + { + toolUseId: `tool-${label}`, + name: 'read_file', + argumentsJson: `{"path":"/${label}` + } + ], + effectiveModel: 'claude-sonnet-4-5' + }) + + for (const mode of ['reasoning_restart', 'exact_replay'] as const) { + test(`an unclosed signed raw tool intent fails before completion in ${mode}`, async () => { + const label = `f1-${mode}` + const acc = makeAccount({ id: 'A', failCount: 4, unhealthyReason: 'transient' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream(incompleteSignedToolEvents(label))], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: mode, + streamMaxAttempts: 1 + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const stream = response.body + if (!stream) throw new Error('expected a streaming response body') + const reader = stream.getReader() + let delivered = '' + const draining = (async () => { + while (true) { + const item = await reader.read() + if (item.done) return + delivered += new TextDecoder().decode(item.value) + } + })() + + await expect(draining).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + cause: { + name: 'SdkEventStreamIterationError', + cause: { name: 'SemanticStreamTruncationError' } + }, + emittedOutput: true + }) + expect(delivered).not.toContain('"finish_reason":"tool_calls"') + expect(delivered).not.toContain('"finish_reason":"stop"') + expect(lookupIncompleteSignedTool(label).refusal).toBe('miss') + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(0) + expect(acc.failCount).toBe(4) + expect(acc.unhealthyReason).toBe('transient') + }) + } + + test('off mode preserves the synthetic completion for an unclosed signed raw tool intent', async () => { + const label = 'f1-off' + const acc = makeAccount({ id: 'A', failCount: 4, unhealthyReason: 'transient' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream(incompleteSignedToolEvents(label))], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'off' + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(body).toContain('"finish_reason":"tool_calls"') + expect(lookupIncompleteSignedTool(label).envelope).toEqual({ + kind: 'reasoningText', + text: `reasoning-${label}`, + signature: `signature-${label}` + }) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect(acc.failCount).toBe(0) + expect(acc.unhealthyReason).toBeUndefined() + }) + test('two healthy concurrent requests on one account both publish their envelopes', async () => { const acc = makeAccount({ id: 'A' }) const { handler } = buildHandler({ @@ -2980,6 +3075,53 @@ describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', } }) + test('an unresolved dialect marker ends as a typed stream failure without a terminal chunk', async () => { + const acc = makeAccount({ id: 'A', failCount: 3, unhealthyReason: 'transient' }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [ + sdkStream([ + { reasoningContentEvent: { text: 'signed-off reasoning' } }, + { + assistantResponseEvent: { + content: '/unfinished' + } + } + ]) + ], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart', + streamMaxAttempts: 1 + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const stream = response.body + if (!stream) throw new Error('expected a streaming response body') + const reader = stream.getReader() + let delivered = '' + const draining = (async () => { + while (true) { + const item = await reader.read() + if (item.done) return + delivered += new TextDecoder().decode(item.value) + } + })() + + await expect(draining).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + cause: { + name: 'SdkEventStreamIterationError', + cause: { name: 'SemanticStreamTruncationError' } + } + }) + expect(delivered).not.toContain('"finish_reason":"stop"') + expect(delivered).not.toContain('"finish_reason":"tool_calls"') + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(0) + expect(acc.failCount).toBe(3) + expect(acc.unhealthyReason).toBe('transient') + }) + test('a recovered stream is one well-framed SSE sequence with a single terminal chunk', async () => { const acc = makeAccount({ id: 'A' }) const { handler } = buildHandler({ @@ -3276,7 +3418,7 @@ describe('RequestHandler.handle — §9 Tier B exact-replay fault-injection matr } }) - test('row clean EOF: a truncation after delivered text routes into exact replay', async () => { + test('row clean EOF: healthy reasoning and text without metadata skips exact replay', async () => { const acc = makeAccount({ id: 'A' }) const logs = captureLogger() try { @@ -3284,11 +3426,7 @@ describe('RequestHandler.handle — §9 Tier B exact-replay fault-injection matr selectResults: [acc], sdkResults: [ sdkStream(reasoningThenText(DELIVERED)), - sdkStream( - reasoningThenText(`${DELIVERED} continued`, { - metadataEvent: { tokenUsage: { outputTokens: 3 } } - }) - ) + sdkStream(reasoningThenText('must not be sent')) ], streaming: true, useRealResponseHandler: true, @@ -3299,18 +3437,11 @@ describe('RequestHandler.handle — §9 Tier B exact-replay fault-injection matr const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) const frames = sseFrames(await response.text()) - expect(fakes.sdkSend).toHaveBeenCalledTimes(2) - expect(joinedDelta(frames, 'content')).toBe(`${DELIVERED} continued`) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(joinedDelta(frames, 'content')).toBe(DELIVERED) expect(joinedDelta(frames, 'reasoning_content')).toBe(THOUGHT) expect(terminalFrames(frames)).toHaveLength(1) - expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([ - expect.objectContaining({ - matchedReasoningChars: THOUGHT.length, - matchedVisibleChars: DELIVERED.length, - divergenceChannel: 'none', - replayOutcome: 'caught_up' - }) - ]) + expect(records(logs.log, REPLAY_TELEMETRY_LOG)).toEqual([]) } finally { logs.restore() } diff --git a/src/__tests__/response-handler-sdk.test.ts b/src/__tests__/response-handler-sdk.test.ts index 46407e0..040f193 100644 --- a/src/__tests__/response-handler-sdk.test.ts +++ b/src/__tests__/response-handler-sdk.test.ts @@ -31,7 +31,11 @@ describe('handleSdkSuccess — non-streaming', () => { const events = [ { assistantResponseEvent: { content: 'Hello ' } }, { assistantResponseEvent: { content: 'world' } }, - { metadataEvent: { tokenUsage: { inputTokens: 12, outputTokens: 3 } } } + { + metadataEvent: { + tokenUsage: { uncachedInputTokens: 12, outputTokens: 3, totalTokens: 15 } + } + } ] const response = await new ResponseHandler().handleSdkSuccess( makeSdkResponse(events), diff --git a/src/__tests__/stream-observability.test.ts b/src/__tests__/stream-observability.test.ts index c6be68b..45f7175 100644 --- a/src/__tests__/stream-observability.test.ts +++ b/src/__tests__/stream-observability.test.ts @@ -103,6 +103,7 @@ describe('StreamObserver — ingestion-time tool intent', () => { expect(broke).toBe(true) expect(toolCallChunks(chunks).length).toBe(0) expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(true) expect(observer.snapshot().sawToolIntent).toBe(true) }) @@ -116,6 +117,7 @@ describe('StreamObserver — ingestion-time tool intent', () => { expect(toolCallChunks(chunks).length).toBe(0) expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(true) }) test('text-dialect tool marker then stream break: sawToolIntent and dialectActive true', async () => { @@ -132,6 +134,7 @@ describe('StreamObserver — ingestion-time tool intent', () => { expect(toolCallChunks(chunks).length).toBe(0) expect(observer.dialectActive).toBe(true) expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(true) // The dialect span itself must never have been streamed as visible text. const visible = chunks.map((c) => contentOf(c) ?? '').join('') expect(visible).not.toContain(' { expect(observer.dialectActive).toBe(true) expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(true) }) }) @@ -190,6 +194,7 @@ describe('StreamObserver — reasoning phase', () => { expect(observer.reasoningPhase).toBe('none') expect(observer.snapshot()).toEqual({ sawToolIntent: false, + hasOpenToolIntent: false, reasoningPhase: 'none', dialectActive: false }) diff --git a/src/__tests__/stream-observer-closure.test.ts b/src/__tests__/stream-observer-closure.test.ts new file mode 100644 index 0000000..a3878e2 --- /dev/null +++ b/src/__tests__/stream-observer-closure.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { transformSdkStream } from '../plugin/streaming/sdk-stream-transformer.js' +import { StreamObserver } from '../plugin/streaming/stream-observer.js' + +function makeSdkResponse(events: readonly unknown[]): object { + return { + generateAssistantResponseResponse: (async function* () { + for (const event of events) yield event + })() + } +} + +async function drain(events: readonly unknown[], observer: StreamObserver): Promise { + for await (const _chunk of transformSdkStream( + makeSdkResponse(events), + 'auto', + 'chatcmpl-observer-closure', + undefined, + observer + )) { + void _chunk + } +} + +describe('StreamObserver — tool intent closure', () => { + test('a raw tool sequence closes only when its final event carries stop true', async () => { + const observer = new StreamObserver() + + await drain( + [ + { toolUseEvent: { toolUseId: 'tu-1', name: 'read', input: '{"path":' } }, + { + toolUseEvent: { toolUseId: 'tu-1', name: 'read', input: '"/tmp/x"}', stop: true } + } + ], + observer + ) + + expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(false) + }) + + test('a dialect marker stays open when finalization parses no complete tool call', async () => { + const observer = new StreamObserver() + + await drain( + [{ assistantResponseEvent: { content: '/tmp' } }], + observer + ) + + expect(observer.hasOpenToolIntent).toBe(true) + }) + + test('a dialect marker closes when finalization parses a complete tool call', async () => { + const observer = new StreamObserver() + + await drain( + [ + { + assistantResponseEvent: { + content: '/tmp/x' + } + } + ], + observer + ) + + expect(observer.hasOpenToolIntent).toBe(false) + }) +}) diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index e5517fc..6cdb148 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -220,23 +220,15 @@ class SemanticStreamTruncationError extends Error { readonly name = 'SemanticStreamTruncationError' constructor() { - super('Kiro SDK event stream ended without completion metadata after semantic output') + super('Kiro SDK event stream ended with an unclosed tool intent') } } function isSemanticTruncation( mode: StreamRecoveryMode, - wrapped: WrappedSdkStream, - emitted: EmittedOutputAccumulator, observer: StreamObserver | undefined ): boolean { - return ( - mode !== 'off' && - !wrapped.completionMetadataSeen() && - (emitted.reasoningText.length > 0 || emitted.visibleText.length > 0) && - emitted.toolUses().length === 0 && - observer?.sawToolIntent !== true - ) + return mode !== 'off' && observer?.hasOpenToolIntent === true } function bufferedSseResponse(chunks: Uint8Array[]): Response { @@ -314,7 +306,7 @@ export class ResponseHandler { const item = await transformed.next() if (item.done) { if (!wrapped.completionMetadataSeen()) lifecycle.onCleanEofWithoutCompletionMetadata?.() - if (isSemanticTruncation(recoveryMode, wrapped, emitted, lifecycle.streamObserver)) { + if (isSemanticTruncation(recoveryMode, lifecycle.streamObserver)) { throw new SdkEventStreamIterationError(new SemanticStreamTruncationError()) } drained = true @@ -447,14 +439,7 @@ export class ResponseHandler { // marker fires here, before completion, rather than at each `item.done`. const complete = async (): Promise => { if (!wrapped.completionMetadataSeen()) lifecycle.onCleanEofWithoutCompletionMetadata?.() - if ( - isSemanticTruncation( - lifecycle.recoveryMode ?? 'off', - wrapped, - emitted, - lifecycle.streamObserver - ) - ) { + if (isSemanticTruncation(lifecycle.recoveryMode ?? 'off', lifecycle.streamObserver)) { throw new SdkEventStreamIterationError(new SemanticStreamTruncationError()) } return this.fireCompletion(lifecycle, reasoning, emitted, model, false) @@ -609,7 +594,7 @@ export class ResponseHandler { toolCalls.push(event.toolUseEvent) } if (event.metadataEvent?.tokenUsage) { - inputTokens = event.metadataEvent.tokenUsage.inputTokens || 0 + inputTokens = event.metadataEvent.tokenUsage.uncachedInputTokens || 0 outputTokens = event.metadataEvent.tokenUsage.outputTokens || 0 } } diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index 130308f..7a21ea4 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -203,7 +203,7 @@ export async function* transformSdkStream( const tc = event.toolUseEvent // Tool intent is recorded at ingestion, not at the end-of-stream flush below: // a stream that dies here has tool intent but zero emitted tool_calls. - observer?.noteRawToolIntent() + observer?.noteRawToolIntent(tc.toolUseId, tc.stop === true) if (tc.name && tc.toolUseId) { if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) { @@ -274,6 +274,7 @@ export async function* transformSdkStream( } const { toolCalls: dialectToolCalls, remainderText } = dialectGate.finalize() + observer?.noteDialectToolResolution(dialectToolCalls.length > 0) if (remainderText) { for (const ev of createTextDeltaEvents(remainderText, streamState)) { const _c = convertToOpenAI(ev, conversationId, model) diff --git a/src/plugin/streaming/stream-observer.ts b/src/plugin/streaming/stream-observer.ts index 2dd5e68..d07eda9 100644 --- a/src/plugin/streaming/stream-observer.ts +++ b/src/plugin/streaming/stream-observer.ts @@ -15,6 +15,8 @@ export interface StreamObservedState { * the dialect gate. */ sawToolIntent: boolean + /** True while a raw or dialect tool intent has not reached a valid close signal. */ + hasOpenToolIntent: boolean reasoningPhase: ReasoningPhase /** True once the dialect gate started withholding text (a marker appeared). */ dialectActive: boolean @@ -32,20 +34,35 @@ export interface StreamObservedState { */ export class StreamObserver { private toolIntent = false + private readonly openRawToolIntents = new Set() + private anonymousRawToolIntent = false + private dialectToolIntentOpen = false private phase: ReasoningPhase = 'none' private dialect = false - /** A raw SDK `toolUseEvent`-family event arrived (name/id completeness irrelevant). */ - noteRawToolIntent(): void { + /** A raw SDK tool sequence advanced; only `stop: true` closes that sequence. */ + noteRawToolIntent(toolUseId: string | undefined, closed: boolean): void { this.toolIntent = true + if (toolUseId) { + if (closed) this.openRawToolIntents.delete(toolUseId) + else this.openRawToolIntents.add(toolUseId) + return + } + this.anonymousRawToolIntent = !closed } /** The dialect gate observed a text-dialect tool-call opening marker. */ noteDialectToolIntent(): void { this.dialect = true + this.dialectToolIntentOpen = true this.toolIntent = true } + /** Records whether finalization resolved the observed marker into a complete call. */ + noteDialectToolResolution(hasCompleteToolCall: boolean): void { + if (this.dialect && hasCompleteToolCall) this.dialectToolIntentOpen = false + } + /** A reasoning/thinking block opened (native reasoning run or inline tag). */ noteReasoningStarted(): void { this.phase = 'active' @@ -60,6 +77,12 @@ export class StreamObserver { return this.toolIntent } + get hasOpenToolIntent(): boolean { + return ( + this.openRawToolIntents.size > 0 || this.anonymousRawToolIntent || this.dialectToolIntentOpen + ) + } + get reasoningPhase(): ReasoningPhase { return this.phase } @@ -71,6 +94,7 @@ export class StreamObserver { snapshot(): StreamObservedState { return { sawToolIntent: this.toolIntent, + hasOpenToolIntent: this.hasOpenToolIntent, reasoningPhase: this.phase, dialectActive: this.dialect } From b3ed493a8b6a9e715da6a8a529cd42890958e47a Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 15:35:39 +0800 Subject: [PATCH 12/21] =?UTF-8?q?fix(request):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=88=9D=E5=A7=8B=20attempt=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=A1=A5=E9=BD=90=E5=8D=95=E6=AC=A1=20onTerminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 让初始 open 与 coordinator 共用幂等终结函数\n- 覆盖同步抛错、异步拒绝与初始 abort --- src/__tests__/recovery-integration.test.ts | 108 +++++++++++++++++++++ src/core/request/recovery-integration.ts | 18 +++- 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/recovery-integration.test.ts diff --git a/src/__tests__/recovery-integration.test.ts b/src/__tests__/recovery-integration.test.ts new file mode 100644 index 0000000..042e7ea --- /dev/null +++ b/src/__tests__/recovery-integration.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'bun:test' +import type { RecoveryAttemptFactory } from '../core/request/recovery-attempt.js' +import { + createLiveRecoveryResponse, + type LiveRecoveryOptions +} from '../core/request/recovery-integration.js' +import type { ManagedAccount } from '../plugin/types.js' + +class InitialAttemptError extends Error { + override readonly name = 'InitialAttemptError' +} + +function makeAccount(): ManagedAccount { + return { + id: 'A', + email: 'A@example.com', + authMethod: 'idc', + region: 'us-east-1', + refreshToken: 'refresh-A', + accessToken: 'access-A', + expiresAt: Date.now() + 60_000, + rateLimitResetTime: 0, + isHealthy: true, + failCount: 0 + } +} + +function recoveryOptions( + attemptFactory: Pick, + signal: AbortSignal = new AbortController().signal +): { readonly options: LiveRecoveryOptions; readonly terminalCalls: () => number } { + let terminalCalls = 0 + return { + options: { + mode: 'reasoning_restart', + maxAttempts: 3, + priorStreamFailures: 0, + signal, + initialAccount: makeAccount(), + attemptFactory, + retryDelay: () => 0, + wait: async () => {}, + selectAlternativeAccount: async () => null, + describeError: (error) => error, + onTerminal: () => { + terminalCalls++ + }, + onCancel: () => {} + }, + terminalCalls: () => terminalCalls + } +} + +async function rejectionOf(promise: Promise): Promise { + return promise.then( + () => undefined, + (error: unknown) => error + ) +} + +describe('createLiveRecoveryResponse — initial attempt terminal ownership', () => { + test('a synchronous initial factory throw propagates and terminates exactly once', async () => { + const original = new InitialAttemptError('synchronous initial open failure') + const harness = recoveryOptions({ + open() { + throw original + } + }) + + const caught = await rejectionOf(createLiveRecoveryResponse(harness.options)) + + expect(caught).toBe(original) + expect(harness.terminalCalls()).toBe(1) + }) + + test('an asynchronous initial factory rejection propagates and terminates exactly once', async () => { + const original = new InitialAttemptError('asynchronous initial open failure') + const harness = recoveryOptions({ + open: () => Promise.reject(original) + }) + + const caught = await rejectionOf(createLiveRecoveryResponse(harness.options)) + + expect(caught).toBe(original) + expect(harness.terminalCalls()).toBe(1) + }) + + test('an abort during initial open propagates its reason and terminates exactly once', async () => { + const controller = new AbortController() + const aborted = Promise.withResolvers() + const harness = recoveryOptions( + { + open: () => aborted.promise + }, + controller.signal + ) + const opening = createLiveRecoveryResponse(harness.options) + const reason = new DOMException('cancelled during initial open', 'AbortError') + + controller.abort(reason) + aborted.reject(reason) + + const caught = await rejectionOf(opening) + + expect(caught).toBe(reason) + expect(harness.terminalCalls()).toBe(1) + }) +}) diff --git a/src/core/request/recovery-integration.ts b/src/core/request/recovery-integration.ts index 0e57795..fa56e51 100644 --- a/src/core/request/recovery-integration.ts +++ b/src/core/request/recovery-integration.ts @@ -11,7 +11,7 @@ export type LiveRecoveryOptions = { readonly priorStreamFailures: number readonly signal: AbortSignal readonly initialAccount: ManagedAccount - readonly attemptFactory: RecoveryAttemptFactory + readonly attemptFactory: Pick readonly retryDelay: (failureCount: number) => number readonly wait: (milliseconds: number, signal: AbortSignal) => Promise readonly selectAlternativeAccount: (excludedAccountId: string) => Promise @@ -25,6 +25,12 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): let nextAccount = options.initialAccount let completedAttempt: SdkStreamingAttempt | undefined let activeLogDetails = (_details: Record = {}): Record => ({}) + let terminalFinished = false + const finishTerminal = (): void => { + if (terminalFinished) return + terminalFinished = true + options.onTerminal() + } const openAttempt = async (attemptIndex: number): Promise => { const result: RecoveryAttemptResult = await options.attemptFactory.open( @@ -37,7 +43,13 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): return result.handle } - const initialAttempt = await openAttempt(1) + let initialAttempt: SdkStreamingAttempt + try { + initialAttempt = await openAttempt(1) + } catch (error) { + finishTerminal() + throw error + } const coordinator = new StreamRecoveryCoordinator({ mode: options.mode, maxAttempts: options.maxAttempts, @@ -99,7 +111,7 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): }) ) }, - onTerminal: options.onTerminal, + onTerminal: finishTerminal, onCancel: options.onCancel }) From 5a544a4a254d09875db2fae0dbfaa31f7bda2931 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 16:18:27 +0800 Subject: [PATCH 13/21] =?UTF-8?q?fix(streaming):=20dialect=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=84=8F=E5=9B=BE=E9=97=AD=E5=90=88=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=89=E6=80=81=E5=B9=B6=E4=B8=8E=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=8C=BA=E8=A7=84=E5=88=99=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/request-handler.test.ts | 145 ++++++++++++++++++ src/__tests__/stream-observer-closure.test.ts | 38 +++++ .../transformers/tool-call-parser.ts | 76 ++++++++- src/plugin/streaming/dialect-gate.ts | 30 ++-- .../streaming/sdk-stream-transformer.ts | 7 +- src/plugin/streaming/stream-observer.ts | 41 +++-- 6 files changed, 305 insertions(+), 32 deletions(-) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index 41eb786..a0cca89 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1609,6 +1609,18 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { } ] + const signedDialectEvents = (label: string, truncated: boolean): unknown[] => [ + { reasoningContentEvent: { text: `reasoning-${label}` } }, + { reasoningContentEvent: { signature: `signature-${label}` } }, + { + assistantResponseEvent: { + content: + `/${label}` + + (truncated ? '/truncated' : '') + } + } + ] + const lookupSignedTool = (label: string) => reasoningCorrelationCache.lookup({ reasoningText: `reasoning-${label}`, @@ -1705,6 +1717,108 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { expect(acc.unhealthyReason).toBeUndefined() }) + for (const mode of ['reasoning_restart', 'exact_replay'] as const) { + test(`a mixed complete and truncated signed dialect fails before completion in ${mode}`, async () => { + const label = `dialect-${mode}` + const acc = makeAccount({ id: 'A', failCount: 4, unhealthyReason: 'transient' }) + const publish = spyOn(reasoningCorrelationCache, 'publish') + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream(signedDialectEvents(label, true))], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: mode, + streamMaxAttempts: 1 + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const stream = response.body + if (!stream) throw new Error('expected a streaming response body') + const reader = stream.getReader() + let delivered = '' + const draining = (async () => { + while (true) { + const item = await reader.read() + if (item.done) return + delivered += new TextDecoder().decode(item.value) + } + })() + + await expect(draining).rejects.toMatchObject({ + name: 'UpstreamUnexpectedError', + cause: { + name: 'SdkEventStreamIterationError', + cause: { name: 'SemanticStreamTruncationError' } + }, + emittedOutput: true + }) + expect(delivered).not.toContain('"finish_reason":"tool_calls"') + expect(delivered).not.toContain('"finish_reason":"stop"') + expect(publish).toHaveBeenCalledTimes(0) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(0) + expect(acc.failCount).toBe(4) + expect(acc.unhealthyReason).toBe('transient') + } finally { + publish.mockRestore() + } + }) + } + + test('off mode preserves mixed dialect emission and completion side effects', async () => { + const label = 'dialect-off' + const acc = makeAccount({ id: 'A', failCount: 4, unhealthyReason: 'transient' }) + const publish = spyOn(reasoningCorrelationCache, 'publish') + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream(signedDialectEvents(label, true))], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'off' + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(body).toContain('/truncated') + expect(body).toContain('"finish_reason":"tool_calls"') + expect(publish).toHaveBeenCalledTimes(1) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + expect(acc.failCount).toBe(0) + expect(acc.unhealthyReason).toBeUndefined() + } finally { + publish.mockRestore() + } + }) + + test('a fully resolved signed dialect keeps the success and publish path intact', async () => { + const label = 'dialect-complete' + const acc = makeAccount({ id: 'A', failCount: 4, unhealthyReason: 'transient' }) + const publish = spyOn(reasoningCorrelationCache, 'publish') + try { + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream(signedDialectEvents(label, false))], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + const body = await response.text() + + expect(body).not.toContain(' { const acc = makeAccount({ id: 'A' }) const { handler } = buildHandler({ @@ -3122,6 +3236,37 @@ describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', expect(acc.unhealthyReason).toBe('transient') }) + for (const [region, content] of [ + [ + 'fenced', + 'Example:\n```xml\n/tmp/x\n```' + ], + ['inline', 'Use `/tmp/x`.'] + ] as const) { + test(`${region} code-only dialect markers stay byte-identical across recovery modes`, async () => { + const bodies: string[] = [] + + for (const mode of ['off', 'reasoning_restart', 'exact_replay'] as const) { + const acc = makeAccount({ id: `A-${mode}` }) + const { handler, fakes } = buildHandler({ + selectResults: [acc], + sdkResults: [sdkStream([{ assistantResponseEvent: { content } }])], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: mode + }) + + const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + bodies.push((await response.text()).replace(/"created":\d+/g, '"created":0')) + expect(fakes.sdkSend).toHaveBeenCalledTimes(1) + expect(fakes.usageTracker.syncUsage).toHaveBeenCalledTimes(1) + } + + expect(bodies[1]).toBe(bodies[0]) + expect(bodies[2]).toBe(bodies[0]) + }) + } + test('a recovered stream is one well-framed SSE sequence with a single terminal chunk', async () => { const acc = makeAccount({ id: 'A' }) const { handler } = buildHandler({ diff --git a/src/__tests__/stream-observer-closure.test.ts b/src/__tests__/stream-observer-closure.test.ts index a3878e2..328c7da 100644 --- a/src/__tests__/stream-observer-closure.test.ts +++ b/src/__tests__/stream-observer-closure.test.ts @@ -67,4 +67,42 @@ describe('StreamObserver — tool intent closure', () => { expect(observer.hasOpenToolIntent).toBe(false) }) + + test('a complete invoke followed by a truncated invoke keeps the dialect intent open', async () => { + const observer = new StreamObserver() + + await drain( + [ + { + assistantResponseEvent: { + content: + '/tmp/x' + + '/truncated' + } + } + ], + observer + ) + + expect(observer.sawToolIntent).toBe(true) + expect(observer.hasOpenToolIntent).toBe(true) + }) + + for (const [region, content] of [ + [ + 'fenced code', + 'Example:\n```xml\n/tmp/x\n```' + ], + ['inline code', 'Use `/tmp/x`.'] + ] as const) { + test(`a marker inside ${region} never opens dialect intent`, async () => { + const observer = new StreamObserver() + + await drain([{ assistantResponseEvent: { content } }], observer) + + expect(observer.sawToolIntent).toBe(false) + expect(observer.hasOpenToolIntent).toBe(false) + expect(observer.dialectActive).toBe(true) + }) + } }) diff --git a/src/infrastructure/transformers/tool-call-parser.ts b/src/infrastructure/transformers/tool-call-parser.ts index 56d8fc8..515ea7e 100644 --- a/src/infrastructure/transformers/tool-call-parser.ts +++ b/src/infrastructure/transformers/tool-call-parser.ts @@ -77,11 +77,20 @@ export function cleanToolCallsFromText(text: string, toolCalls: ToolCall[]): str // deepseek DSML opening marker — the exact U+FF5C ('|') form observed leaking. export const DSML_MARKER = '<\uFF5CDSML\uFF5Cfunction_calls' +export const TEXT_TOOL_CALL_OPENING_MARKERS = [ + ' start < e && end > s) } +function openingMarkerStarts(text: string, codeRanges: Array<[number, number]>): number[] { + const starts: number[] = [] + for (const marker of TEXT_TOOL_CALL_OPENING_MARKERS) { + let from = 0 + for (;;) { + const start = text.indexOf(marker, from) + if (start === -1) break + const end = start + marker.length + if (!overlapsCode(start, end, codeRanges)) starts.push(start) + from = end + } + } + return starts +} + +export function firstTextToolCallOpeningMarkerIndex(text: string): number { + const starts = openingMarkerStarts(text, computeCodeRanges(text)) + return starts.length === 0 ? -1 : Math.min(...starts) +} + function overlapsClaimed(start: number, end: number, claimed: Array<[number, number]>): boolean { return claimed.some(([s, e]) => start < e && end > s) } @@ -171,16 +200,18 @@ function matchAnthropicXml( const invokeRe = /([\s\S]*?)<\/invoke>/g const toolCalls: ToolCall[] = [] + const resolvedOpeningStarts = [start] let im: RegExpExecArray | null while ((im = invokeRe.exec(bm[0])) !== null) { const name = im[1] if (!name) continue toolCalls.push(toolCallFromInvoke(name, im[2] ?? '')) + resolvedOpeningStarts.push(start + im.index) } // Only treat as a tool-call span if at least one invoke parsed; otherwise // it is not a real dialect payload — leave the text untouched. if (toolCalls.length === 0) continue - matches.push({ start, end, toolCalls }) + matches.push({ start, end, toolCalls, resolvedOpeningStarts }) claimed.push([start, end]) } @@ -194,7 +225,12 @@ function matchAnthropicXml( if (!name) continue if (overlapsCode(start, end, codeRanges)) continue if (overlapsClaimed(start, end, claimed)) continue - matches.push({ start, end, toolCalls: [toolCallFromInvoke(name, sm[2] ?? '')] }) + matches.push({ + start, + end, + toolCalls: [toolCallFromInvoke(name, sm[2] ?? '')], + resolvedOpeningStarts: [start] + }) claimed.push([start, end]) } @@ -247,7 +283,12 @@ function matchDsml( } } - matches.push({ start, end, toolCalls }) + matches.push({ + start, + end, + toolCalls, + resolvedOpeningStarts: toolCalls.length > 0 ? [start] : [] + }) claimed.push([start, end]) } return matches @@ -282,7 +323,8 @@ function matchBracket( matches.push({ start, end, - toolCalls: [{ toolUseId: genToolUseId(), name, input }] + toolCalls: [{ toolUseId: genToolUseId(), name, input }], + resolvedOpeningStarts: [] }) claimed.push([start, end]) } @@ -299,10 +341,15 @@ function matchBracket( * - candidates inside fenced/inline code are skipped; * - a dialect that yields no parseable call is stripped, never fabricated. */ -export function parseTextToolCalls(text: string): { toolCalls: ToolCall[]; cleanedText: string } { - if (!text) return { toolCalls: [], cleanedText: text } +export function parseTextToolCalls(text: string): { + toolCalls: ToolCall[] + cleanedText: string + resolution: DialectToolResolution +} { + if (!text) return { toolCalls: [], cleanedText: text, resolution: 'none' } const codeRanges = computeCodeRanges(text) + const openings = openingMarkerStarts(text, codeRanges) const claimed: Array<[number, number]> = [] const matches: DialectMatch[] = [ @@ -311,11 +358,18 @@ export function parseTextToolCalls(text: string): { toolCalls: ToolCall[]; clean ...matchBracket(text, codeRanges, claimed) ] - if (matches.length === 0) return { toolCalls: [], cleanedText: text } + if (matches.length === 0) { + return { + toolCalls: [], + cleanedText: text, + resolution: openings.length === 0 ? 'none' : 'incomplete' + } + } matches.sort((a, b) => a.start - b.start) const toolCalls: ToolCall[] = [] + const resolvedOpenings = new Set(matches.flatMap((match) => match.resolvedOpeningStarts)) let cleanedText = '' let cursor = 0 for (const mt of matches) { @@ -326,5 +380,11 @@ export function parseTextToolCalls(text: string): { toolCalls: ToolCall[]; clean } cleanedText += text.slice(cursor) - return { toolCalls, cleanedText } + const resolution = + openings.length === 0 + ? 'none' + : openings.every((opening) => resolvedOpenings.has(opening)) + ? 'complete' + : 'incomplete' + return { toolCalls, cleanedText, resolution } } diff --git a/src/plugin/streaming/dialect-gate.ts b/src/plugin/streaming/dialect-gate.ts index 8e6c7fd..2797b5d 100644 --- a/src/plugin/streaming/dialect-gate.ts +++ b/src/plugin/streaming/dialect-gate.ts @@ -1,6 +1,8 @@ +import type { DialectToolResolution } from '../../infrastructure/transformers/tool-call-parser.js' import { - DSML_MARKER, - parseTextToolCalls + firstTextToolCallOpeningMarkerIndex, + parseTextToolCalls, + TEXT_TOOL_CALL_OPENING_MARKERS } from '../../infrastructure/transformers/tool-call-parser.js' import type { ToolCall } from '../types.js' @@ -9,14 +11,12 @@ import type { ToolCall } from '../types.js' // further visible text (buffer it) so a dialect span is never emitted as // visible `delta.content`. Authoritative parsing happens only at finalization // on the FULL accumulated text (never per-fragment). -const OPENING_MARKERS = [' this.emitted ? cleanedText.slice(this.emitted) : '' - return { toolCalls, remainderText } + return { toolCalls, remainderText, resolution } } } diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index 7a21ea4..b308ac1 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -53,7 +53,8 @@ export async function* transformSdkStream( const toChunk = (ev: StreamEvent): any => { if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') { const safe = dialectGate.push(ev.delta.text ?? '') - if (dialectGate.suppressing) observer?.noteDialectToolIntent() + if (dialectGate.suppressing) observer?.noteDialectGateActive() + observer?.noteDialectToolIntent(dialectGate.hasToolIntent) if (!safe) return null const gated: StreamEvent = { ...ev, delta: { ...ev.delta, text: safe } } return convertToOpenAI(gated, conversationId, model) @@ -273,8 +274,8 @@ export async function* transformSdkStream( } } - const { toolCalls: dialectToolCalls, remainderText } = dialectGate.finalize() - observer?.noteDialectToolResolution(dialectToolCalls.length > 0) + const { toolCalls: dialectToolCalls, remainderText, resolution } = dialectGate.finalize() + observer?.noteDialectToolResolution(resolution) if (remainderText) { for (const ev of createTextDeltaEvents(remainderText, streamState)) { const _c = convertToOpenAI(ev, conversationId, model) diff --git a/src/plugin/streaming/stream-observer.ts b/src/plugin/streaming/stream-observer.ts index d07eda9..06fc1a7 100644 --- a/src/plugin/streaming/stream-observer.ts +++ b/src/plugin/streaming/stream-observer.ts @@ -1,3 +1,5 @@ +import type { DialectToolResolution } from '../../infrastructure/transformers/tool-call-parser.js' + /** * Where the reasoning/thinking channel stands at the moment of observation. * @@ -33,16 +35,17 @@ export interface StreamObservedState { * naive replay would double-execute a tool. */ export class StreamObserver { - private toolIntent = false + private rawToolIntentSeen = false private readonly openRawToolIntents = new Set() private anonymousRawToolIntent = false + private dialectToolIntentSeen = false private dialectToolIntentOpen = false private phase: ReasoningPhase = 'none' private dialect = false /** A raw SDK tool sequence advanced; only `stop: true` closes that sequence. */ noteRawToolIntent(toolUseId: string | undefined, closed: boolean): void { - this.toolIntent = true + this.rawToolIntentSeen = true if (toolUseId) { if (closed) this.openRawToolIntents.delete(toolUseId) else this.openRawToolIntents.add(toolUseId) @@ -51,16 +54,32 @@ export class StreamObserver { this.anonymousRawToolIntent = !closed } - /** The dialect gate observed a text-dialect tool-call opening marker. */ - noteDialectToolIntent(): void { + noteDialectGateActive(): void { this.dialect = true - this.dialectToolIntentOpen = true - this.toolIntent = true } - /** Records whether finalization resolved the observed marker into a complete call. */ - noteDialectToolResolution(hasCompleteToolCall: boolean): void { - if (this.dialect && hasCompleteToolCall) this.dialectToolIntentOpen = false + /** Synchronize the currently observable non-code-region dialect marker. */ + noteDialectToolIntent(present: boolean): void { + this.dialectToolIntentSeen = present + this.dialectToolIntentOpen = present + } + + /** Records whether finalization resolved every non-code-region opening marker. */ + noteDialectToolResolution(resolution: DialectToolResolution): void { + switch (resolution) { + case 'none': + this.dialectToolIntentSeen = false + this.dialectToolIntentOpen = false + return + case 'complete': + this.dialectToolIntentSeen = true + this.dialectToolIntentOpen = false + return + case 'incomplete': + this.dialectToolIntentSeen = true + this.dialectToolIntentOpen = true + return + } } /** A reasoning/thinking block opened (native reasoning run or inline tag). */ @@ -74,7 +93,7 @@ export class StreamObserver { } get sawToolIntent(): boolean { - return this.toolIntent + return this.rawToolIntentSeen || this.dialectToolIntentSeen } get hasOpenToolIntent(): boolean { @@ -93,7 +112,7 @@ export class StreamObserver { snapshot(): StreamObservedState { return { - sawToolIntent: this.toolIntent, + sawToolIntent: this.sawToolIntent, hasOpenToolIntent: this.hasOpenToolIntent, reasoningPhase: this.phase, dialectActive: this.dialect From e96a9c73a51dd886f8d6deeaeaa96264cde758db Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 16:29:30 +0800 Subject: [PATCH 14/21] =?UTF-8?q?fix(request):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=88=9D=E5=A7=8B=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=BF=87=E6=97=A9=E6=89=A7=E8=A1=8C=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=BA=A7=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初始 openAttempt(1) 失败会被外层循环在同一入站请求内重试,但它原先复用 coordinator 的 finishTerminal(),进而触发 cleanupRequest() 摘除入站 abort listener 并 latch requestCleanupDone,导致第 2 次 attempt 收不到 caller abort(实测 retrySignalAborted=false),启用 SDK deadline 时还可能残留定时器。 按 DECISION 3 把终止所有权分层:onTerminal 仅在 Response 真正交付时触发 (coordinator 语义不变,仍恰一次),初始 open 失败改走新增的 onInitialOpenFailure,只做 attempt 级释放(endUpstreamWait),请求级清理 仍由外层 finally 的幂等 cleanupRequest() 持有。 新增回归用例覆盖「初始 open 失败 → 外层重试 → 第 2 次 send 阻塞 → caller abort」,断言重试 attempt 的 signal 以同一 reason 对象中断且队列槽只释放 一次;补齐 delivered-response 的请求级 terminal 恰一次用例。 --- src/__tests__/recovery-integration.test.ts | 74 ++++++++++++++++++++-- src/__tests__/request-handler.test.ts | 70 +++++++++++++++++++- src/core/request/recovery-integration.ts | 14 +++- src/core/request/request-handler.ts | 4 ++ 4 files changed, 153 insertions(+), 9 deletions(-) diff --git a/src/__tests__/recovery-integration.test.ts b/src/__tests__/recovery-integration.test.ts index 042e7ea..4990582 100644 --- a/src/__tests__/recovery-integration.test.ts +++ b/src/__tests__/recovery-integration.test.ts @@ -28,8 +28,13 @@ function makeAccount(): ManagedAccount { function recoveryOptions( attemptFactory: Pick, signal: AbortSignal = new AbortController().signal -): { readonly options: LiveRecoveryOptions; readonly terminalCalls: () => number } { +): { + readonly options: LiveRecoveryOptions + readonly terminalCalls: () => number + readonly initialFailureCalls: () => number +} { let terminalCalls = 0 + let initialFailureCalls = 0 return { options: { mode: 'reasoning_restart', @@ -45,9 +50,13 @@ function recoveryOptions( onTerminal: () => { terminalCalls++ }, + onInitialOpenFailure: () => { + initialFailureCalls++ + }, onCancel: () => {} }, - terminalCalls: () => terminalCalls + terminalCalls: () => terminalCalls, + initialFailureCalls: () => initialFailureCalls } } @@ -59,7 +68,7 @@ async function rejectionOf(promise: Promise): Promise { } describe('createLiveRecoveryResponse — initial attempt terminal ownership', () => { - test('a synchronous initial factory throw propagates and terminates exactly once', async () => { + test('a synchronous initial factory throw propagates and releases the attempt only', async () => { const original = new InitialAttemptError('synchronous initial open failure') const harness = recoveryOptions({ open() { @@ -70,10 +79,11 @@ describe('createLiveRecoveryResponse — initial attempt terminal ownership', () const caught = await rejectionOf(createLiveRecoveryResponse(harness.options)) expect(caught).toBe(original) - expect(harness.terminalCalls()).toBe(1) + expect(harness.initialFailureCalls()).toBe(1) + expect(harness.terminalCalls()).toBe(0) }) - test('an asynchronous initial factory rejection propagates and terminates exactly once', async () => { + test('an asynchronous initial factory rejection propagates and releases the attempt only', async () => { const original = new InitialAttemptError('asynchronous initial open failure') const harness = recoveryOptions({ open: () => Promise.reject(original) @@ -82,10 +92,29 @@ describe('createLiveRecoveryResponse — initial attempt terminal ownership', () const caught = await rejectionOf(createLiveRecoveryResponse(harness.options)) expect(caught).toBe(original) - expect(harness.terminalCalls()).toBe(1) + expect(harness.initialFailureCalls()).toBe(1) + expect(harness.terminalCalls()).toBe(0) + }) + + test('an already aborted request propagates its reason and releases the attempt only', async () => { + const reason = new DOMException('cancelled before initial open', 'AbortError') + const controller = new AbortController() + controller.abort(reason) + const harness = recoveryOptions( + { + open: () => Promise.reject(reason) + }, + controller.signal + ) + + const caught = await rejectionOf(createLiveRecoveryResponse(harness.options)) + + expect(caught).toBe(reason) + expect(harness.initialFailureCalls()).toBe(1) + expect(harness.terminalCalls()).toBe(0) }) - test('an abort during initial open propagates its reason and terminates exactly once', async () => { + test('an abort during initial open propagates its reason and releases the attempt only', async () => { const controller = new AbortController() const aborted = Promise.withResolvers() const harness = recoveryOptions( @@ -103,6 +132,37 @@ describe('createLiveRecoveryResponse — initial attempt terminal ownership', () const caught = await rejectionOf(opening) expect(caught).toBe(reason) + expect(harness.initialFailureCalls()).toBe(1) + expect(harness.terminalCalls()).toBe(0) + }) + + test('a delivered response keeps request-level terminal ownership exactly once', async () => { + const drained: IteratorResult = { done: true, value: undefined } + let closeCalls = 0 + let completeCalls = 0 + const harness = recoveryOptions({ + open: async () => ({ + account: makeAccount(), + logDetails: () => ({}), + handle: { + chunks: { next: async () => drained, return: async () => drained }, + observed: () => ({ emitted: { visibleChars: 0, toolCount: 0 }, sawToolIntent: false }), + close: async () => { + closeCalls++ + }, + complete: async () => { + completeCalls++ + } + } + }) + }) + + const response = await createLiveRecoveryResponse(harness.options) + await response.text() + + expect(completeCalls).toBe(1) + expect(closeCalls).toBeGreaterThanOrEqual(1) expect(harness.terminalCalls()).toBe(1) + expect(harness.initialFailureCalls()).toBe(0) }) }) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index a0cca89..ce3d13f 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -2386,6 +2386,74 @@ describe('RequestHandler.handle — cancellation and queue release', () => { expect(sendCalls).toBe(3) }) + test('an initial recovery open failure keeps caller cancellation live for the retried attempt', async () => { + const acc = makeAccount({ id: 'A' }) + const { handler } = buildHandler({ + selectResults: [acc, acc], + streaming: true, + useRealResponseHandler: true, + streamRecoveryMode: 'reasoning_restart' + }) + installImmediateStreamBackoff(handler) + const internals = handler as unknown as { + makeSdkClient: () => { + send: (command: unknown, options: { abortSignal: AbortSignal }) => Promise + } + } + let sendCalls = 0 + let retrySignal: AbortSignal | undefined + let notifyRetrySendStarted: (() => void) | undefined + const retrySendStarted = new Promise((resolve) => { + notifyRetrySendStarted = resolve + }) + internals.makeSdkClient = () => ({ + send: async (_command, options) => { + sendCalls++ + if (sendCalls === 1) { + return sdkStream([], new Error('upstream reset before the first event')) + } + if (sendCalls === 2) { + retrySignal = options.abortSignal + notifyRetrySendStarted?.() + return new Promise((_resolve, reject) => { + options.abortSignal.addEventListener( + 'abort', + () => reject(options.abortSignal.reason), + { once: true } + ) + }) + } + return sdkStream([ + { assistantResponseEvent: { content: 'queued request succeeds' } }, + { metadataEvent: { tokenUsage: { outputTokens: 1 } } } + ]) + } + }) + const controller = new AbortController() + const request = handler.handle( + KIRO_URL, + { body: JSON.stringify({}), signal: controller.signal }, + noToast + ) + + await retrySendStarted + const reason = new DOMException('cancelled during the retried attempt', 'AbortError') + controller.abort(reason) + + expect(retrySignal?.aborted).toBe(true) + expect(retrySignal?.reason).toBe(reason) + const caught = await request.then( + () => undefined, + (error: unknown) => error + ) + expect(caught).toBe(reason) + expect(sendCalls).toBe(2) + + const next = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) + expect(streamedText(await next.text())).toBe('queued request succeeds') + expect(sendCalls).toBe(3) + }) + test('periodic upstream activity allows a thinking stream to outlive the timeout window', async () => { const acc = makeAccount({ id: 'A' }) const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -3177,7 +3245,7 @@ describe('RequestHandler.handle — §9 Tier A recovery fault-injection matrix', (call) => call[0] === STREAM_FAILURE_LOG ) ).toBe(false) - // The truncation predicate needs reasoning or content, so a zero-event + // The truncation predicate requires an unclosed tool intent, so a zero-event // stream is only ever marked, never turned into a recoverable failure. expect(records(logs.warn, STREAM_MISSING_COMPLETION_LOG)[0]).toMatchObject({ outcome: 'clean_eof_without_completion_metadata', diff --git a/src/core/request/recovery-integration.ts b/src/core/request/recovery-integration.ts index fa56e51..8cf8947 100644 --- a/src/core/request/recovery-integration.ts +++ b/src/core/request/recovery-integration.ts @@ -16,7 +16,19 @@ export type LiveRecoveryOptions = { readonly wait: (milliseconds: number, signal: AbortSignal) => Promise readonly selectAlternativeAccount: (excludedAccountId: string) => Promise readonly describeError: (error: unknown) => unknown + /** + * Request-level terminal ownership. Lifecycle ownership transfers to the + * Response, so this only ever fires on a path where the Response was actually + * delivered to the caller — i.e. from the coordinator, exactly once. + */ readonly onTerminal: () => void + /** + * Attempt-level release for an initial `openAttempt(1)` failure. That failure is + * pre-output and is re-thrown for the caller's outer retry loop, so ownership has + * NOT transferred: this callback must not run request-level cleanup and must not + * detach the inbound abort listener, or the retry loses caller cancellation. + */ + readonly onInitialOpenFailure: () => void readonly onCancel: (reason: unknown) => void } @@ -47,7 +59,7 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): try { initialAttempt = await openAttempt(1) } catch (error) { - finishTerminal() + options.onInitialOpenFailure() throw error } const coordinator = new StreamRecoveryCoordinator({ diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 9f04f70..545f2ce 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -407,6 +407,10 @@ export class RequestHandler { this.accountSelector.selectAlternativeAccount(new Set([accountId])), describeError, onTerminal: cleanupRequest, + // Attempt-level only: an initial-open failure is pre-output and is + // retried below, so request-level cleanup (which detaches the inbound + // abort listener) must stay with the outer finally. + onInitialOpenFailure: endUpstreamWait, onCancel: (reason) => requestController.abort(reason) }) From 1fff159a012d1c5375f52cf6c59972e55d02aead Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 17:16:36 +0800 Subject: [PATCH 15/21] =?UTF-8?q?fix(streaming):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=A1=A3=E4=B8=8B=E6=9C=AA=E9=97=AD=E5=90=88=20dialect=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=B3=84=E6=BC=8F=E6=96=87=E6=9C=AC=E4=B8=8E?= =?UTF-8?q?=E9=83=A8=E5=88=86=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/request-handler.test.ts | 22 ++- src/__tests__/tool-dialect-parser.test.ts | 131 +++++++++++++++++- src/core/request/response-handler.ts | 6 +- .../streaming/sdk-stream-transformer.ts | 25 +++- 4 files changed, 176 insertions(+), 8 deletions(-) diff --git a/src/__tests__/request-handler.test.ts b/src/__tests__/request-handler.test.ts index ce3d13f..2511366 100644 --- a/src/__tests__/request-handler.test.ts +++ b/src/__tests__/request-handler.test.ts @@ -1755,6 +1755,12 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { }) expect(delivered).not.toContain('"finish_reason":"tool_calls"') expect(delivered).not.toContain('"finish_reason":"stop"') + // Zero leakage: neither the half-parsed span nor the resolved sibling call + // may reach the consumer on a turn declared semantically truncated. + expect(delivered).not.toContain(' { const response = await handler.handle(KIRO_URL, { body: JSON.stringify({}) }, noToast) const body = await response.text() + // `off` keeps the documented carve-out verbatim, leak included: the truncated + // span streams as visible text and the resolved sibling call still ships. expect(body).toContain('/truncated') + expect(body).toContain(' { try { const { handler, fakes } = buildHandler({ selectResults: [acc], - sdkResults: [sdkStream(signedDialectEvents(label, false))], + sdkResults: [ + sdkStream([ + ...signedDialectEvents(label, false), + { assistantResponseEvent: { content: ` trailing-${label}` } } + ]) + ], streaming: true, useRealResponseHandler: true, streamRecoveryMode: 'reasoning_restart' @@ -1809,6 +1825,10 @@ describe('RequestHandler.handle — reasoning signature safety gates', () => { const body = await response.text() expect(body).not.toContain(' { +async function collectSdkChunks( + events: any[], + suppressIncompleteDialect?: boolean +): Promise { const chunks: any[] = [] - for await (const chunk of transformSdkStream(makeSdkResponse(events), 'auto', 'chatcmpl-test')) { + for await (const chunk of transformSdkStream( + makeSdkResponse(events), + 'auto', + 'chatcmpl-test', + undefined, + undefined, + suppressIncompleteDialect + )) { chunks.push(chunk) } return chunks } +function joinedContent(chunks: any[]): string { + return chunks + .map((c) => contentOf(c)) + .filter((s): s is string => s !== undefined) + .join('') +} + +function toolCallChunks(chunks: any[]): any[] { + return chunks.filter((c) => c?.choices?.[0]?.delta?.tool_calls !== undefined) +} + +function finishReasons(chunks: any[]): unknown[] { + return chunks + .map((c) => c?.choices?.[0]?.finish_reason) + .filter((r) => r !== null && r !== undefined) +} + function contentOf(chunk: any): string | undefined { return chunk?.choices?.[0]?.delta?.content } @@ -221,3 +248,103 @@ describe('streaming suppression — no dialect leaks into delta.content', () => expect(toolStartChunks(chunks).length).toBe(0) }) }) + +describe('transformSdkStream — suppressIncompleteDialect', () => { + const MIXED_DIALECT = + '/kept' + + '/truncated' + + test('Given a mixed complete+truncated dialect, When suppression is on, Then nothing from the span is emitted', async () => { + const chunks = await collectSdkChunks( + [{ assistantResponseEvent: { content: MIXED_DIALECT } }], + true + ) + + const content = joinedContent(chunks) + expect(content).not.toContain(' { + const chunks = await collectSdkChunks( + [{ assistantResponseEvent: { content: MIXED_DIALECT } }], + false + ) + + const content = joinedContent(chunks) + expect(content).toContain(' { + const stamp = (chunks: any[]): string => + JSON.stringify(chunks, (key, value) => { + if (key === 'created') return 0 + // Run-scoped: the parser mints `tool__` for a dialect call the + // upstream never gave an id, so it is not part of the emit contract. + if (key === 'id' && typeof value === 'string' && /^tool_\d+_[a-z0-9]+$/.test(value)) { + return 'tool_synthetic' + } + return value + }) + + const omitted = await collectSdkChunks([{ assistantResponseEvent: { content: MIXED_DIALECT } }]) + const explicit = await collectSdkChunks( + [{ assistantResponseEvent: { content: MIXED_DIALECT } }], + false + ) + + expect(stamp(explicit)).toBe(stamp(omitted)) + }) + + test('Given a truncated dialect alongside a closed raw tool call, When suppression is on, Then the partial tool set is withheld', async () => { + const chunks = await collectSdkChunks( + [ + { + toolUseEvent: { toolUseId: 'tu-1', name: 'grep', input: '{"q":"x"}', stop: true } + }, + { assistantResponseEvent: { content: '/half' } } + ], + true + ) + + expect(toolCallChunks(chunks).length).toBe(0) + expect(joinedContent(chunks)).not.toContain(' { + const chunks = await collectSdkChunks( + [ + { + assistantResponseEvent: { + content: + '/kept all done' + } + } + ], + true + ) + + expect(joinedContent(chunks)).toBe(' all done') + expect(toolStartChunks(chunks).length).toBe(1) + expect(toolStartChunks(chunks)[0]!.choices[0].delta.tool_calls[0].function.name).toBe('read') + expect(finishReasons(chunks)).toEqual(['tool_calls']) + }) + + test('Given a code-region-only marker, When suppression is on, Then the text streams verbatim', async () => { + const content = + 'Example:\n```xml\n/tmp/x\n```' + const chunks = await collectSdkChunks([{ assistantResponseEvent: { content } }], true) + + expect(joinedContent(chunks)).toBe(content) + expect(toolCallChunks(chunks).length).toBe(0) + expect(finishReasons(chunks)).toEqual(['stop']) + }) +}) diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index 6cdb148..74869e3 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -288,7 +288,8 @@ export class ResponseHandler { model, conversationId, reasoning, - lifecycle.streamObserver + lifecycle.streamObserver, + recoveryMode !== 'off' ) const prefetched: unknown[] = [] let prefetchIndex = 0 @@ -430,7 +431,8 @@ export class ResponseHandler { model, conversationId, reasoning, - lifecycle.streamObserver + lifecycle.streamObserver, + (lifecycle.recoveryMode ?? 'off') !== 'off' ) const buffered: Uint8Array[] = [] // One shared publication point for all three completion paths. Duplicating it diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index b308ac1..7ac39c5 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -19,13 +19,21 @@ import { * the transformer feeds them and never reads them back, so neither can change * an emitted chunk. They stay separate trailing parameters rather than one * options bag because every existing call site passes positionally. + * + * `suppressIncompleteDialect` is the one input that DOES change emission, and + * only in a single shape: a text-dialect span that never closed. Set it when a + * stream recovery mode is active, so a turn the response-handler is about to + * declare semantically truncated delivers nothing from that span. Leave it + * `false` (the default, and what `stream_recovery_mode: 'off'` must pass) to get + * byte-identical output to the historical path. */ export async function* transformSdkStream( sdkResponse: any, model: string, conversationId: string, reasoningAccumulator?: ReasoningAccumulator, - observer?: StreamObserver + observer?: StreamObserver, + suppressIncompleteDialect = false ): AsyncGenerator { const thinkingRequested = true @@ -275,8 +283,15 @@ export async function* transformSdkStream( } const { toolCalls: dialectToolCalls, remainderText, resolution } = dialectGate.finalize() + // Notified unconditionally: detection must stay independent of suppression, or + // the response-handler's truncation verdict would move with this flag. observer?.noteDialectToolResolution(resolution) - if (remainderText) { + // An unclosed span means this turn is about to be declared truncated, so its + // remainder text (half an invocation) and its resolved siblings (a partial tool + // set) must not reach the consumer. Discard the ambiguous span; never guess its + // boundary. + const dropIncompleteDialect = suppressIncompleteDialect && resolution === 'incomplete' + if (remainderText && !dropIncompleteDialect) { for (const ev of createTextDeltaEvents(remainderText, streamState)) { const _c = convertToOpenAI(ev, conversationId, model) if (_c !== null) yield _c @@ -288,7 +303,11 @@ export async function* transformSdkStream( if (_c !== null) yield _c } - if (dialectToolCalls.length > 0) { + if (dropIncompleteDialect) { + // Raw SDK tool calls collected on this same turn are part of that partial + // set, so they are withheld too. + toolCalls.length = 0 + } else if (dialectToolCalls.length > 0) { for (const btc of dialectToolCalls) { toolCalls.push({ toolUseId: btc.toolUseId, From 88d4e166b05fa40a0d015f464ee24a8faaa70f8f Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 17:32:06 +0800 Subject: [PATCH 16/21] =?UTF-8?q?fix(history):=20=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=20content=20=E7=BD=AE=E7=A9=BA=E4=BB=A5?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=8E=86=E5=8F=B2=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collapseAgenticLoops 把折叠环内非首条 assistant 轮的 content 改写为 `[system: tool calling continues]`,仅改写带 toolUses 的条目,因此该串与工具调用 共现、构成大量 in-context 示例(实测一次真实会话 944/2370 条),模型把它当成 "要调工具时 content 就该是这种终止式短句"并逐字回吐,进而只输出散文、不发工具调用。 改为 `content: ''`,与官方 aws/language-servers 的 `content: msg.body`(工具专用轮 即 `""`,无占位符)一致;toolUses 与 reasoningContent 原样保留。不恢复原文本, 避免重新引入折叠本就要省掉的 token 成本。 上游作者已因同一原因换掉过 'Continue'(680fc10),随后 ec828d4 为折叠重复开场白 又引入了这个更像指令的标记。 同步更新三处钉死该标记的既有测试。 --- src/__tests__/history-builder.test.ts | 5 +++-- src/__tests__/reasoning-signature-roundtrip.test.ts | 2 +- src/__tests__/reasoning-text-recovery.test.ts | 4 ++-- src/infrastructure/transformers/history-builder.ts | 7 ++++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/__tests__/history-builder.test.ts b/src/__tests__/history-builder.test.ts index 929e856..094b1e9 100644 --- a/src/__tests__/history-builder.test.ts +++ b/src/__tests__/history-builder.test.ts @@ -160,9 +160,10 @@ describe('collapseAgenticLoops', () => { ] const history = [...mkPair(1), ...mkPair(2)] const result = collapseAgenticLoops(history) - // First pair keeps its original assistant text; subsequent pair's text is replaced. + // First pair keeps its original assistant text; subsequent pair's text is emptied, + // matching the official Kiro IDE shape for a tool-only assistant turn. expect(result[0]?.assistantResponseMessage?.content).toBe('preamble 1') - expect(result[2]?.assistantResponseMessage?.content).toBe('[system: tool calling continues]') + expect(result[2]?.assistantResponseMessage?.content).toBe('') // toolUses are preserved through the collapse. expect(result[2]?.assistantResponseMessage?.toolUses?.[0]?.toolUseId).toBe('u2') }) diff --git a/src/__tests__/reasoning-signature-roundtrip.test.ts b/src/__tests__/reasoning-signature-roundtrip.test.ts index 3463460..1e7339a 100644 --- a/src/__tests__/reasoning-signature-roundtrip.test.ts +++ b/src/__tests__/reasoning-signature-roundtrip.test.ts @@ -308,7 +308,7 @@ describe('request-side merge and collapse safety', () => { expect(firstResponse?.reasoningContent?.reasoningText?.signature).toBe(SIG_A) expect(secondResponse?.reasoningContent?.reasoningText?.signature).toBe(SIG_B) expect(thirdResponse?.reasoningContent?.reasoningText?.signature).toBe(`${SIG_A}-third`) - expect(secondResponse?.content).toBe('[system: tool calling continues]') + expect(secondResponse?.content).toBe('') }) test('reasoning stays attached to the assistant turn that produced matching tool uses', () => { diff --git a/src/__tests__/reasoning-text-recovery.test.ts b/src/__tests__/reasoning-text-recovery.test.ts index 939452a..8adde47 100644 --- a/src/__tests__/reasoning-text-recovery.test.ts +++ b/src/__tests__/reasoning-text-recovery.test.ts @@ -224,12 +224,12 @@ describe('buildHistory — reasoning_content recovery', () => { const msgs = toolLoopMsgs(['t1', 't2', 't3', 't4']) const serialized = JSON.stringify(buildHistory(msgs, MODEL)) // The loop's first turn and its trailing (uncollapsed) turn keep reasoning; - // the intermediate turns are replaced by the existing placeholder. + // the intermediate turns are emptied, with no placeholder text left behind. expect(serialized).toContain('t1') expect(serialized).toContain('t4') expect(serialized).not.toContain('t2') expect(serialized).not.toContain('t3') - expect(serialized).toContain('[system: tool calling continues]') + expect(serialized).not.toContain('tool calling continues') }) }) diff --git a/src/infrastructure/transformers/history-builder.ts b/src/infrastructure/transformers/history-builder.ts index ba8ef07..ffe5af0 100644 --- a/src/infrastructure/transformers/history-builder.ts +++ b/src/infrastructure/transformers/history-builder.ts @@ -18,6 +18,11 @@ import { deduplicateToolResults } from './tool-transformer.js' * * Strips text from intermediate ASST(toolUses)→USER(toolResults) pairs, keeping only the * first assistant text and all tool_use/tool_result pairs. + * + * Collapsed turns carry `content: ''`, matching the official Kiro IDE shape for a + * tool-only assistant turn. A placeholder string here is not cosmetic: the model reads + * it as dozens of in-context examples of what an assistant turn looks like when it is + * about to call a tool, and echoes it verbatim instead of emitting a real tool call. */ export function collapseAgenticLoops(history: CodeWhispererMessage[]): CodeWhispererMessage[] { if (history.length < 4) return history @@ -61,7 +66,7 @@ export function collapseAgenticLoops(history: CodeWhispererMessage[]): CodeWhisp if (!assistantResponse.toolUses) continue result.push({ assistantResponseMessage: { - content: '[system: tool calling continues]', + content: '', toolUses: assistantResponse.toolUses, ...(assistantResponse.reasoningContent !== undefined ? { reasoningContent: assistantResponse.reasoningContent } From 85a7eaace190a6c3d1ffd23d974730c201373cca Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 17:32:20 +0800 Subject: [PATCH 17/21] =?UTF-8?q?fix(history):=20=E6=B8=85=E6=B4=97?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=9B=9E=E6=94=BE=E7=9A=84=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=B1=A1=E6=9F=93=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只停止产出污染并不自愈:模型已把标记写进自己的可见输出,OpenCode 把该输出存为 assistant 历史,之后每次请求都回放它。旧会话会永久自我复制这个污染。 在 parseAssistantMessage(入站 assistant 解析的唯一入口,同时覆盖折叠与非折叠 路径)剥除两个标记字面量:`[system: tool calling continues]` 与 `[system: conversation continues]`。字节安全:不含标记的文本按原引用返回; 剥除时把标记两侧的空白重新发射为两侧原本就有的最小分隔符,不动周围真实内容。 先例:Quorinex/Kiro-Go `stripPollutedToolCallText` 及其回归测试 TestScrubsClientReplayedToolCallText —— 同域同机制,入站清洗是其修复四步中的第二步。 新增回归覆盖:字节安全、入站清洗(含 reasoning_content)、客户端回放污染不上线、 >=3 对折叠工具链全文序列化零标记,以及两项相邻 wire 约束(不发空 toolUses 数组、 currentMessage.content 仅在带 toolResults 时可空)。 --- src/__tests__/history-pollution-scrub.test.ts | 214 ++++++++++++++++++ .../transformers/message-transformer.ts | 45 +++- 2 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/history-pollution-scrub.test.ts diff --git a/src/__tests__/history-pollution-scrub.test.ts b/src/__tests__/history-pollution-scrub.test.ts new file mode 100644 index 0000000..958c252 --- /dev/null +++ b/src/__tests__/history-pollution-scrub.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from 'bun:test' +import { + parseAssistantMessage, + stripPollutionMarkers +} from '../infrastructure/transformers/message-transformer.js' +import { transformToSdkRequest } from '../plugin/request.js' +import type { CodeWhispererMessage, KiroAuthDetails } from '../plugin/types.js' + +const MODEL = 'claude-sonnet-4-5' + +const auth: KiroAuthDetails = { + refresh: 'r', + access: 'access-token', + expires: Date.now() + 3_600_000, + authMethod: 'idc', + region: 'us-east-1' +} + +const TOOL_MARKER = '[system: tool calling continues]' +const CONVERSATION_MARKER = '[system: conversation continues]' + +function toolChainMsgs(pairs: number, firstAssistantText: string): any[] { + const msgs: any[] = [{ role: 'user', content: 'run the whole chain' }] + for (let turn = 1; turn <= pairs; turn++) { + msgs.push({ + role: 'assistant', + content: turn === 1 ? firstAssistantText : `step ${turn}`, + tool_calls: [{ id: `tu${turn}`, function: { name: 'calc', arguments: `{"n":${turn}}` } }] + }) + msgs.push({ role: 'tool', content: `result ${turn}`, tool_call_id: `tu${turn}` }) + } + return msgs +} + +function allEntries(state: any): CodeWhispererMessage[] { + return [...((state.history ?? []) as CodeWhispererMessage[]), state.currentMessage] +} + +describe('stripPollutionMarkers', () => { + test('text carrying no marker is returned byte-for-byte', () => { + const samples = [ + 'a plain answer', + 'trailing whitespace kept ', + 'blank\n\n\nline run kept', + ' leading kept', + 'a bracketed [system: something else] phrase', + '' + ] + for (const sample of samples) expect(stripPollutionMarkers(sample)).toBe(sample) + }) + + test('a marker that is the entire text collapses to an empty string', () => { + expect(stripPollutionMarkers(TOOL_MARKER)).toBe('') + expect(stripPollutionMarkers(CONVERSATION_MARKER)).toBe('') + expect(stripPollutionMarkers(`\n\n${TOOL_MARKER}\n`)).toBe('') + }) + + test('a marker at the start is removed without disturbing the answer', () => { + expect(stripPollutionMarkers(`${TOOL_MARKER} code? no output. Need run.`)).toBe( + 'code? no output. Need run.' + ) + expect(stripPollutionMarkers(`${TOOL_MARKER}\n\nHere is the fix.`)).toBe('Here is the fix.') + }) + + test('a marker between two fragments leaves the separator they already had', () => { + expect(stripPollutionMarkers(`A\n\n${TOOL_MARKER}\n\nB`)).toBe('A\n\nB') + expect(stripPollutionMarkers(`A\n${CONVERSATION_MARKER}\nB`)).toBe('A\nB') + expect(stripPollutionMarkers(`A ${TOOL_MARKER} B`)).toBe('A B') + }) + + test('both marker literals are removed from the same text', () => { + expect(stripPollutionMarkers(`${CONVERSATION_MARKER}\n\nreal\n\n${TOOL_MARKER}`)).toBe('real') + }) +}) + +describe('parseAssistantMessage inbound scrubbing', () => { + test('a replayed marker is scrubbed while toolUses survive untouched', () => { + const parsed = parseAssistantMessage({ + role: 'assistant', + content: `${TOOL_MARKER} reading the file now`, + tool_calls: [{ id: 'tu1', function: { name: 'read', arguments: '{"path":"a.ts"}' } }] + }) + expect(parsed.content).toBe('reading the file now') + expect(parsed.toolUses).toEqual([{ input: { path: 'a.ts' }, name: 'read', toolUseId: 'tu1' }]) + }) + + test('a marker replayed inside reasoning_content is scrubbed too', () => { + const parsed = parseAssistantMessage( + { role: 'assistant', content: 'answer', reasoning_content: `${TOOL_MARKER}\n\nthought` }, + { recoverReasoning: true } + ) + expect(parsed.thinking).toBe('thought') + }) + + test('an unpolluted assistant message parses byte-for-byte', () => { + const message = { + role: 'assistant', + content: 'plain answer\n\n\nwith gaps ', + reasoning_content: 'raw thought ' + } + const parsed = parseAssistantMessage(message, { recoverReasoning: true }) + expect(parsed.content).toBe('plain answer\n\n\nwith gaps ') + expect(parsed.thinking).toBe('raw thought ') + }) +}) + +describe('client-replayed pollution never reaches the wire', () => { + test('a polluted assistant turn is scrubbed before the request is built', () => { + const msgs = toolChainMsgs(2, `${TOOL_MARKER} Let me check the file.`) + msgs.push({ role: 'user', content: 'now summarize' }) + + const request = transformToSdkRequest({ messages: msgs }, MODEL, auth) + const serialized = JSON.stringify(request.conversationState) + + expect(serialized).not.toContain('tool calling continues') + expect(serialized).toContain('Let me check the file.') + }) + + test('pollution replayed as the current assistant turn is scrubbed', () => { + const request = transformToSdkRequest( + { + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: `${TOOL_MARKER}\n\nfinal answer` } + ] + }, + MODEL, + auth + ) + const entries = allEntries(request.conversationState) + const replayed = entries.flatMap((e) => (e.assistantResponseMessage ? [e] : [])) + + expect(replayed.at(-1)?.assistantResponseMessage?.content).toBe('final answer') + }) +}) + +describe('long tool chain leaves no marker on the wire', () => { + const msgs = toolChainMsgs(5, 'starting the chain') + const request = transformToSdkRequest({ messages: msgs }, MODEL, auth) + const state: any = request.conversationState + const serialized = JSON.stringify(state) + + test('the collapse produced at least three emptied assistant turns', () => { + const emptied = ((state.history ?? []) as CodeWhispererMessage[]).filter( + (e) => e.assistantResponseMessage?.toolUses && e.assistantResponseMessage.content === '' + ) + expect(emptied.length).toBeGreaterThanOrEqual(3) + }) + + test('the full serialization contains neither marker literal', () => { + expect(serialized).not.toContain('tool calling continues') + expect(serialized).not.toContain('conversation continues') + }) + + test('no assistant turn carries an empty toolUses array', () => { + expect(serialized).not.toContain('"toolUses":[]') + for (const entry of allEntries(state)) { + const toolUses = entry.assistantResponseMessage?.toolUses + if (toolUses !== undefined) expect(toolUses.length).toBeGreaterThan(0) + } + }) + + test('currentMessage content is empty only when tool results accompany it', () => { + const uim = state.currentMessage.userInputMessage + const toolResults = uim.userInputMessageContext?.toolResults ?? [] + if (uim.content.length === 0) expect(toolResults.length).toBeGreaterThan(0) + else expect(uim.content.length).toBeGreaterThan(0) + }) +}) + +describe('wire shape invariants across turn kinds', () => { + const shapes: Array<{ label: string; messages: any[] }> = [ + { label: 'plain user turn', messages: [{ role: 'user', content: 'hello' }] }, + { + label: 'assistant-final turn', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: 'a' } + ] + }, + { label: 'tool-final turn', messages: toolChainMsgs(3, 'begin') }, + { + label: 'empty user turn', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: 'a' }, + { role: 'user', content: '' } + ] + } + ] + + for (const shape of shapes) { + test(`${shape.label}: content non-empty unless toolResults are present`, () => { + const state: any = transformToSdkRequest( + { messages: shape.messages }, + MODEL, + auth + ).conversationState + const uim = state.currentMessage.userInputMessage + const toolResults = uim.userInputMessageContext?.toolResults ?? [] + if (uim.content.length === 0) expect(toolResults.length).toBeGreaterThan(0) + else expect(uim.content.length).toBeGreaterThan(0) + }) + + test(`${shape.label}: no empty toolUses array is emitted`, () => { + const state: any = transformToSdkRequest( + { messages: shape.messages }, + MODEL, + auth + ).conversationState + expect(JSON.stringify(state)).not.toContain('"toolUses":[]') + }) + } +}) diff --git a/src/infrastructure/transformers/message-transformer.ts b/src/infrastructure/transformers/message-transformer.ts index 035780e..e4c21bf 100644 --- a/src/infrastructure/transformers/message-transformer.ts +++ b/src/infrastructure/transformers/message-transformer.ts @@ -97,6 +97,45 @@ export interface ParsedAssistantMessage { toolUses: Array<{ input: any; name: string; toolUseId: string }> } +/** + * Literal separators this plugin has written into replayed assistant turns. + * + * They are self-replicating: the model copies them into its own visible output, the + * client persists that output as assistant history, and replays it on every later + * request. Removing the producer alone therefore leaves existing sessions poisoned + * forever, so inbound assistant text is scrubbed as history is rebuilt. + */ +const POLLUTION_MARKERS = [ + '[system: tool calling continues]', + '[system: conversation continues]' +] as const + +const POLLUTION_MARKER_PATTERN = /(\s*)\[system: (?:tool calling|conversation) continues\](\s*)/g + +function newlineCount(text: string): number { + let count = 0 + for (const ch of text) if (ch === '\n') count += 1 + return count +} + +/** + * Remove replayed pollution markers from one inbound assistant text. + * + * Text without any marker is returned byte-for-byte. When a marker is removed, the + * whitespace it was surrounded by is re-emitted as the smallest separator the two + * remaining fragments already had, so real content keeps its own shape. + */ +export function stripPollutionMarkers(text: string): string { + if (!text || !POLLUTION_MARKERS.some((marker) => text.includes(marker))) return text + return text + .replace(POLLUTION_MARKER_PATTERN, (_match, before: string, after: string) => { + const newlines = Math.min(2, Math.max(newlineCount(before), newlineCount(after))) + if (newlines > 0) return '\n'.repeat(newlines) + return before.length > 0 || after.length > 0 ? ' ' : '' + }) + .trim() +} + /** * Extract the reasoning text an OpenAI-compatible assistant message carries at * the top level. @@ -191,7 +230,11 @@ export function parseAssistantMessage( if (!thinking && options?.recoverReasoning) thinking = extractReasoningText(m) - return { content, thinking, toolUses } + return { + content: stripPollutionMarkers(content), + thinking: stripPollutionMarkers(thinking), + toolUses + } } export function applyThinkingToContent( From be056d38f77a0fc8724f6e35f0eae2d4f394c2a3 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 17:46:21 +0800 Subject: [PATCH 18/21] =?UTF-8?q?fix(history):=20=20=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=9B=9E=E6=94=BE=E6=94=B6=E6=95=9B=E4=B8=BA=E6=B4=BB?= =?UTF-8?q?=E8=B7=83=E5=B7=A5=E5=85=B7=E7=8E=AF=E5=86=85=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E4=B8=80=E8=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reasoning-signature-roundtrip.test.ts | 3 +- src/__tests__/reasoning-text-recovery.test.ts | 9 +- .../thinking-text-replay-bound.test.ts | 158 ++++++++++++++++++ .../transformers/history-builder.ts | 12 +- .../transformers/message-transformer.ts | 25 +++ src/plugin/reasoning/request-replay.ts | 27 ++- src/plugin/request.ts | 12 +- 7 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/thinking-text-replay-bound.test.ts diff --git a/src/__tests__/reasoning-signature-roundtrip.test.ts b/src/__tests__/reasoning-signature-roundtrip.test.ts index 1e7339a..60befc3 100644 --- a/src/__tests__/reasoning-signature-roundtrip.test.ts +++ b/src/__tests__/reasoning-signature-roundtrip.test.ts @@ -400,7 +400,8 @@ describe('request-side merge and collapse safety', () => { expect(responses).toHaveLength(1) expect(responses[0]?.reasoningContent).toBeUndefined() - expect(responses[0]?.content).toContain('reasoning-1') + expect(responses[0]?.content).toContain('answer-1') + expect(responses[0]?.content).not.toContain('reasoning-1') expect(responses[0]?.content).toContain('reasoning-2') }) }) diff --git a/src/__tests__/reasoning-text-recovery.test.ts b/src/__tests__/reasoning-text-recovery.test.ts index 8adde47..45ad727 100644 --- a/src/__tests__/reasoning-text-recovery.test.ts +++ b/src/__tests__/reasoning-text-recovery.test.ts @@ -152,11 +152,12 @@ describe('findActiveToolLoopStart', () => { }) describe('buildHistory — reasoning_content recovery', () => { - test('string content + reasoning_content becomes content', () => { + test('reasoning_content becomes content on the loop-latest turn only', () => { const msgs = toolLoopMsgs(['thought one', 'thought two']) const history = buildHistory(msgs, MODEL) const asst = asstEntries(history) - expect(asst[0]?.content).toBe('thought one\n\nstep 1') + expect(asst.at(-1)?.content).toBe('thought two\n\nstep 2') + expect(asst[0]?.content).toBe('step 1') expect(asst[0]?.toolUses).toEqual([{ input: { n: 1 }, name: 'calc', toolUseId: 'tu1' }]) }) @@ -223,10 +224,8 @@ describe('buildHistory — reasoning_content recovery', () => { test('collapseAgenticLoops still strips intermediate turns, bounding replay cost', () => { const msgs = toolLoopMsgs(['t1', 't2', 't3', 't4']) const serialized = JSON.stringify(buildHistory(msgs, MODEL)) - // The loop's first turn and its trailing (uncollapsed) turn keep reasoning; - // the intermediate turns are emptied, with no placeholder text left behind. - expect(serialized).toContain('t1') expect(serialized).toContain('t4') + expect(serialized).not.toContain('t1') expect(serialized).not.toContain('t2') expect(serialized).not.toContain('t3') expect(serialized).not.toContain('tool calling continues') diff --git a/src/__tests__/thinking-text-replay-bound.test.ts b/src/__tests__/thinking-text-replay-bound.test.ts new file mode 100644 index 0000000..da49c49 --- /dev/null +++ b/src/__tests__/thinking-text-replay-bound.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + findActiveToolLoopStart, + findThinkingTextReplayIndex +} from '../infrastructure/transformers/message-transformer.js' +import { reasoningCorrelationCache } from '../plugin/reasoning/correlation-cache.js' +import { transformToSdkRequest } from '../plugin/request.js' +import type { CodeWhispererMessage, KiroAuthDetails } from '../plugin/types.js' + +const MODEL = 'claude-opus-5' +const SIGNATURE_PREFIX = `sig-${'S'.repeat(320)}` + +const auth: KiroAuthDetails = { + refresh: 'refresh-token', + access: 'access-token', + expires: Date.now() + 3_600_000, + authMethod: 'idc', + region: 'us-east-1' +} + +type AssistantResponse = NonNullable + +function missTurn(turn: number): Record { + return { + role: 'assistant', + content: `visible-${turn}`, + reasoning_content: `thought-${turn}`, + tool_calls: [{ id: `tu-${turn}`, function: { name: 'calc', arguments: `{"turn":${turn}}` } }] + } +} + +function hitTurn(turn: number): Record { + const message = missTurn(turn) + reasoningCorrelationCache.publish({ + reasoningText: `thought-${turn}`, + visibleText: `visible-${turn}`, + toolUses: [{ toolUseId: `tu-${turn}`, name: 'calc', argumentsJson: `{"turn":${turn}}` }], + effectiveModel: MODEL, + envelope: { + kind: 'reasoningText', + text: `thought-${turn}`, + signature: `${SIGNATURE_PREFIX}-${turn}` + }, + loopId: `loop:tu-${turn}`, + accountId: 'account-A', + attemptId: `attempt:tu-${turn}` + }) + return message +} + +function toolLoop( + turns: number, + makeTurn: (turn: number) => Record +): Record[] { + const msgs: Record[] = [{ role: 'user', content: 'run the chain' }] + for (let turn = 1; turn <= turns; turn++) { + msgs.push(makeTurn(turn)) + msgs.push({ role: 'tool', content: `result-${turn}`, tool_call_id: `tu-${turn}` }) + } + return msgs +} + +function buildRequest( + messages: Record[] +): ReturnType { + return transformToSdkRequest({ messages }, MODEL, auth) +} + +function assistantResponses( + request: ReturnType +): AssistantResponse[] { + return (request.conversationState.history ?? []).flatMap((entry) => + entry.assistantResponseMessage ? [entry.assistantResponseMessage] : [] + ) +} + +function countThinkingBlocks(request: ReturnType): number { + return (JSON.stringify(request).match(//g) ?? []).length +} + +beforeEach(() => { + reasoningCorrelationCache.clearAllForTests() +}) + +describe('findThinkingTextReplayIndex', () => { + test('selects the latest assistant turn, which sits inside the active tool loop', () => { + const msgs = toolLoop(3, missTurn) + const index = findThinkingTextReplayIndex(msgs) + + expect(msgs[index]).toBe(msgs.at(-2)) + expect(index).toBeGreaterThanOrEqual(findActiveToolLoopStart(msgs)) + }) + + test('a conversation without an assistant turn selects nothing', () => { + expect(findThinkingTextReplayIndex([{ role: 'user', content: 'q' }])).toBe(-1) + }) +}) + +describe('signature-miss replay is bounded to one turn', () => { + test('several miss turns in one active loop leave only the latest turn thinking', () => { + const request = buildRequest(toolLoop(3, missTurn)) + + expect(countThinkingBlocks(request)).toBe(1) + expect(JSON.stringify(request)).toContain('thought-3') + }) + + test('earlier miss turns keep their visible content and tool uses', () => { + const responses = assistantResponses(buildRequest(toolLoop(2, missTurn))) + const earlier = responses[0] + + expect(earlier?.content).toBe('visible-1') + expect(earlier?.toolUses).toEqual([{ input: { turn: 1 }, name: 'calc', toolUseId: 'tu-1' }]) + expect(responses.at(-1)?.content).toBe('thought-2\n\nvisible-2') + }) + + test('six collapsible miss pairs still produce exactly one thinking block', () => { + const request = buildRequest(toolLoop(6, missTurn)) + + expect(countThinkingBlocks(request)).toBe(1) + expect(JSON.stringify(request)).toContain('thought-6') + }) + + test('a single assistant turn still replays its thinking text', () => { + const request = buildRequest(toolLoop(1, missTurn)) + + expect(countThinkingBlocks(request)).toBe(1) + expect(JSON.stringify(request)).toContain('thought-1') + }) +}) + +describe('signature hits are untouched by the thinking-text bound', () => { + test('every hit turn replays native reasoningContent and no thinking text', () => { + const request = buildRequest(toolLoop(3, hitTurn)) + const signatures = assistantResponses(request).flatMap((response) => + response.reasoningContent?.reasoningText?.signature + ? [response.reasoningContent.reasoningText.signature] + : [] + ) + + expect(signatures).toEqual([ + `${SIGNATURE_PREFIX}-1`, + `${SIGNATURE_PREFIX}-2`, + `${SIGNATURE_PREFIX}-3` + ]) + expect(countThinkingBlocks(request)).toBe(0) + }) + + test('a hit on the latest turn suppresses the text fallback it would otherwise get', () => { + const msgs = toolLoop(2, missTurn) + msgs[msgs.length - 2] = hitTurn(2) + const request = buildRequest(msgs) + + expect(countThinkingBlocks(request)).toBe(0) + expect(assistantResponses(request).at(-1)?.reasoningContent?.reasoningText?.signature).toBe( + `${SIGNATURE_PREFIX}-2` + ) + }) +}) diff --git a/src/infrastructure/transformers/history-builder.ts b/src/infrastructure/transformers/history-builder.ts index ffe5af0..6a58a2a 100644 --- a/src/infrastructure/transformers/history-builder.ts +++ b/src/infrastructure/transformers/history-builder.ts @@ -6,7 +6,11 @@ import { } from '../../plugin/image-handler.js' import { reconstructAssistantResponse } from '../../plugin/reasoning/request-replay.js' import type { CodeWhispererMessage } from '../../plugin/types' -import { findActiveToolLoopStart, getContentText } from './message-transformer.js' +import { + findActiveToolLoopStart, + findThinkingTextReplayIndex, + getContentText +} from './message-transformer.js' import { deduplicateToolResults } from './tool-transformer.js' /** @@ -99,6 +103,7 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag string >() const loopStart = findActiveToolLoopStart(msgs) + const thinkingReplayIndex = findThinkingTextReplayIndex(msgs) for (let i = 0; i < msgs.length - 1; i++) { const m = msgs[i] if (!m) continue @@ -164,7 +169,10 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag } }) } else if (m.role === 'assistant') { - const reconstructed = reconstructAssistantResponse(m, resolved, i >= loopStart) + const reconstructed = reconstructAssistantResponse(m, resolved, { + recoverReasoning: i >= loopStart, + allowThinkingText: i === thinkingReplayIndex + }) const arm = reconstructed.response if (!arm.content && !arm.toolUses && !arm.reasoningContent) { diff --git a/src/infrastructure/transformers/message-transformer.ts b/src/infrastructure/transformers/message-transformer.ts index e4c21bf..ccd8b0d 100644 --- a/src/infrastructure/transformers/message-transformer.ts +++ b/src/infrastructure/transformers/message-transformer.ts @@ -179,6 +179,31 @@ export function findActiveToolLoopStart(msgs: any[]): number { return start } +/** + * Index of the single assistant turn allowed to flatten its chain-of-thought into + * `` text when the reasoning-signature cache misses. + * + * Flattening reasoning into assistant text on every replayed turn teaches the model, + * across dozens of in-context examples, that an assistant turn is its own scratchpad — + * which is how a session ends up narrating its next step instead of issuing a tool + * call. Both vendors instead require reasoning to be handed back as an untouched + * structured object, and Kiro's `AssistantResponseMessage` schema carries no reasoning + * field at all. The bound is one turn rather than zero because signature recovery + * deliberately misses on every Tier A stream recovery, so dropping the fallback + * outright would strip recovered turns of all reasoning continuity. + * + * The chosen turn is the most recent assistant message, which inside an in-flight tool + * loop is by construction that loop's own latest assistant turn, because + * `findActiveToolLoopStart` returns the start of a *trailing* run. Returns -1 when the + * conversation carries no assistant turn. + */ +export function findThinkingTextReplayIndex(msgs: any[]): number { + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i]?.role === 'assistant') return i + } + return -1 +} + function isToolResultMessage(m: any): boolean { if (m.role === 'tool') return true if (m.role !== 'user') return false diff --git a/src/plugin/reasoning/request-replay.ts b/src/plugin/reasoning/request-replay.ts index fd7f534..edac8cc 100644 --- a/src/plugin/reasoning/request-replay.ts +++ b/src/plugin/reasoning/request-replay.ts @@ -48,22 +48,33 @@ function resolveSignedReasoning(input: ReplayLookupInput): NativeReasoningConten } } +export interface AssistantReplayScope { + /** Native signed replay is allowed only for one unmerged turn in the active tool loop. */ + readonly recoverReasoning: boolean + /** The signature-miss `` text fallback is allowed for one turn only. */ + readonly allowThinkingText: boolean +} + /** * Rebuild one assistant turn from its inbound OpenAI-compatible shape. * - * Native replay is allowed only for one unmerged source turn in the active - * tool loop. A cache miss (or any refusal) retains Wave 1's thinking-text - * fallback byte-for-byte. + * A signature hit emits native `reasoningContent` and no thinking text. On a miss the + * thinking-text fallback is retained byte-for-byte, but only for the turn the caller + * marks with `allowThinkingText`; every other turn keeps its visible content and tool + * uses and drops the thinking text. This is the single funnel for both channels the + * fallback reaches — `response.content` and the `fallbackContent` the history builder + * restores when it merges adjacent assistant turns. */ export function reconstructAssistantResponse( message: unknown, effectiveModel: string, - recoverReasoning: boolean + scope: AssistantReplayScope ): ReconstructedAssistantResponse { - const parsed = parseAssistantMessage(message, { recoverReasoning }) - const fallbackContent = applyThinkingToContent(parsed.content, parsed.thinking) + const parsed = parseAssistantMessage(message, { recoverReasoning: scope.recoverReasoning }) + const thinkingText = scope.allowThinkingText ? parsed.thinking : '' + const fallbackContent = applyThinkingToContent(parsed.content, thinkingText) const reasoningContent = - recoverReasoning && !spansMultipleAssistantSourceTurns(message) + scope.recoverReasoning && !spansMultipleAssistantSourceTurns(message) ? resolveSignedReasoning({ message, visibleText: parsed.content, @@ -72,7 +83,7 @@ export function reconstructAssistantResponse( }) : undefined const response: AssistantResponse = { - content: applyThinkingToContent(parsed.content, parsed.thinking, reasoningContent !== undefined) + content: applyThinkingToContent(parsed.content, thinkingText, reasoningContent !== undefined) } if (parsed.toolUses.length > 0) response.toolUses = parsed.toolUses if (reasoningContent !== undefined) response.reasoningContent = reasoningContent diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 7e6faa4..37166f8 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -9,6 +9,7 @@ import { } from '../infrastructure/transformers/history-builder.js' import { findOriginalToolCall, + findThinkingTextReplayIndex, getContentText, mergeAdjacentMessages } from '../infrastructure/transformers/message-transformer.js' @@ -103,6 +104,7 @@ function buildCodeWhispererRequest( const lastMsg = msgs[msgs.length - 1] if (lastMsg && lastMsg.role === 'assistant' && getContentText(lastMsg) === '{') msgs.pop() const cwTools = tools ? convertToolsToCodeWhisperer(tools) : [] + const thinkingReplayIndex = findThinkingTextReplayIndex(msgs) let history = buildHistory(msgs, resolved) const curMsg = msgs[msgs.length - 1] @@ -118,7 +120,10 @@ function buildCodeWhispererRequest( const lastHistEntry = history[history.length - 1] const historyEndsWithUser = lastHistEntry?.userInputMessage if (historyEndsWithUser) { - const reconstructed = reconstructAssistantResponse(prevMsg, resolved, false) + const reconstructed = reconstructAssistantResponse(prevMsg, resolved, { + recoverReasoning: false, + allowThinkingText: msgs.length - 2 === thinkingReplayIndex + }) const arm = reconstructed.response if (arm.content || arm.toolUses || arm.reasoningContent) { history.push({ assistantResponseMessage: arm }) @@ -133,7 +138,10 @@ function buildCodeWhispererRequest( const curImgs: any[] = [] if (curMsg.role === 'assistant') { - const arm = reconstructAssistantResponse(curMsg, resolved, true).response + const arm = reconstructAssistantResponse(curMsg, resolved, { + recoverReasoning: true, + allowThinkingText: msgs.length - 1 === thinkingReplayIndex + }).response if (arm.content || arm.toolUses || arm.reasoningContent) { history.push({ assistantResponseMessage: arm }) From e19851b8ceb1b25c066d7abb0a3eeeb418d0b62a Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 18:00:00 +0800 Subject: [PATCH 19/21] =?UTF-8?q?fix(history):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E7=9B=B8=E9=82=BB=E5=90=8C=E8=A7=92=E8=89=B2=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E4=BB=A5=E6=B6=88=E9=99=A4=E5=90=88=E6=88=90=20assistant=20?= =?UTF-8?q?=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/history-builder.test.ts | 8 +- src/__tests__/history-turn-merge.test.ts | 312 ++++++++++++++++++ .../transformers/history-builder.ts | 78 ++++- src/plugin/image-handler.ts | 2 +- src/plugin/request.ts | 8 +- 5 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/history-turn-merge.test.ts diff --git a/src/__tests__/history-builder.test.ts b/src/__tests__/history-builder.test.ts index 094b1e9..1601496 100644 --- a/src/__tests__/history-builder.test.ts +++ b/src/__tests__/history-builder.test.ts @@ -113,19 +113,15 @@ describe('buildHistory', () => { expect(history.some((h) => h.assistantResponseMessage)).toBe(false) }) - test('consecutive user turns get a synthetic assistant separator injected', () => { + test('consecutive user turns are merged into one instead of separated', () => { const msgs = [ { role: 'user', content: 'u1' }, { role: 'user', content: 'u2' }, { role: 'user', content: 'trailing' } ] - // mergeAdjacentMessages is NOT applied inside buildHistory, so two user turns - // in a row trigger the [system: conversation continues] separator. const history = buildHistory(msgs, MODEL) expect(history).toEqual([ - { userInputMessage: { content: 'u1', modelId: MODEL, origin: 'AI_EDITOR' } }, - { assistantResponseMessage: { content: '[system: conversation continues]' } }, - { userInputMessage: { content: 'u2', modelId: MODEL, origin: 'AI_EDITOR' } } + { userInputMessage: { content: 'u1\n\nu2', modelId: MODEL, origin: 'AI_EDITOR' } } ]) }) }) diff --git a/src/__tests__/history-turn-merge.test.ts b/src/__tests__/history-turn-merge.test.ts new file mode 100644 index 0000000..8a5e888 --- /dev/null +++ b/src/__tests__/history-turn-merge.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from 'bun:test' +import { buildHistory } from '../infrastructure/transformers/history-builder.js' +import { transformToSdkRequest } from '../plugin/request.js' +import type { CodeWhispererMessage, KiroAuthDetails } from '../plugin/types.js' + +const MODEL = 'claude-sonnet-4-5' +const CONVERSATION_MARKER = '[system: conversation continues]' + +const auth: KiroAuthDetails = { + refresh: 'r', + access: 'access-token', + expires: Date.now() + 3_600_000, + authMethod: 'idc', + region: 'us-east-1' +} + +type TurnKind = 'assistant' | 'user' + +function turnKind(entry: CodeWhispererMessage): TurnKind { + return entry.assistantResponseMessage ? 'assistant' : 'user' +} + +function alternationViolations(history: CodeWhispererMessage[]): string[] { + const violations: string[] = [] + for (let i = 1; i < history.length; i++) { + const previous = history[i - 1] + const current = history[i] + if (!previous || !current) continue + const kind = turnKind(current) + if (turnKind(previous) === kind) violations.push(`${i - 1}->${i} both ${kind}`) + } + return violations +} + +function separatorTurnCount(history: CodeWhispererMessage[]): number { + return history.filter((entry) => { + const assistant = entry.assistantResponseMessage + return !!assistant && !assistant.content && !assistant.toolUses && !assistant.reasoningContent + }).length +} + +function assistantContents(state: { + currentMessage: CodeWhispererMessage + history?: CodeWhispererMessage[] +}): string[] { + return [...(state.history ?? []), state.currentMessage].flatMap((entry) => + entry.assistantResponseMessage ? [entry.assistantResponseMessage.content] : [] + ) +} + +function toolChainMsgs(pairs: number): any[] { + const msgs: any[] = [{ role: 'user', content: 'run the whole chain' }] + for (let turn = 1; turn <= pairs; turn++) { + msgs.push({ + role: 'assistant', + content: `step ${turn}`, + tool_calls: [{ id: `tu${turn}`, function: { name: 'calc', arguments: `{"n":${turn}}` } }] + }) + msgs.push({ role: 'tool', content: `result ${turn}`, tool_call_id: `tu${turn}` }) + } + return msgs +} + +describe('buildHistory alternation invariant', () => { + const shapes: Array<{ label: string; msgs: any[] }> = [ + { + label: 'user -> user', + msgs: [ + { role: 'user', content: 'u1' }, + { role: 'user', content: 'u2' }, + { role: 'user', content: 'trailing' } + ] + }, + { + label: 'user -> tool', + msgs: [ + { role: 'assistant', content: 'call', tool_calls: [{ id: 't1', function: { name: 'f' } }] }, + { role: 'user', content: 'meanwhile, also do this' }, + { role: 'tool', content: 'result', tool_call_id: 't1' }, + { role: 'user', content: 'trailing' } + ] + }, + { + label: 'tool -> user', + msgs: [ + { role: 'assistant', content: 'call', tool_calls: [{ id: 't1', function: { name: 'f' } }] }, + { role: 'tool', content: 'result', tool_call_id: 't1' }, + { role: 'user', content: 'now summarize' }, + { role: 'user', content: 'trailing' } + ] + }, + { + label: 'tool -> tool', + msgs: [ + { + role: 'assistant', + content: 'call two', + tool_calls: [ + { id: 't1', function: { name: 'f' } }, + { id: 't2', function: { name: 'g' } } + ] + }, + { role: 'tool', content: 'first', tool_call_id: 't1' }, + { role: 'tool', content: 'second', tool_call_id: 't2' }, + { role: 'user', content: 'trailing' } + ] + }, + { + label: 'assistant -> assistant', + msgs: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: 'part one' }, + { role: 'assistant', content: 'part two' }, + { role: 'user', content: 'trailing' } + ] + }, + { + label: 'long tool chain (6 pairs)', + msgs: [...toolChainMsgs(6), { role: 'user', content: 'x' }] + } + ] + + for (const shape of shapes) { + test(`${shape.label}: no two consecutive entries share a kind`, () => { + expect(alternationViolations(buildHistory(shape.msgs, MODEL))).toEqual([]) + }) + + test(`${shape.label}: no synthesized assistant separator is emitted`, () => { + expect(separatorTurnCount(buildHistory(shape.msgs, MODEL))).toBe(0) + }) + } +}) + +describe('merging preserves content and tool results', () => { + test('a user turn followed by a tool turn keeps both texts and both tool results', () => { + const history = buildHistory( + [ + { + role: 'user', + content: [ + { type: 'text', text: 'read it' }, + { type: 'tool_result', tool_use_id: 'tu1', content: 'from the user turn' } + ] + }, + { role: 'tool', content: 'from the tool turn', tool_call_id: 'tu2' }, + { role: 'user', content: 'trailing' } + ], + MODEL + ) + + expect(history).toHaveLength(1) + const merged = history[0]?.userInputMessage + expect(merged?.content).toBe('read it') + expect(merged?.userInputMessageContext?.toolResults).toEqual([ + { content: [{ text: 'from the user turn' }], status: 'success', toolUseId: 'tu1' }, + { content: [{ text: 'from the tool turn' }], status: 'success', toolUseId: 'tu2' } + ]) + }) + + test('a tool turn followed by a user turn keeps the later text and the earlier results', () => { + const history = buildHistory( + [ + { role: 'assistant', content: 'call', tool_calls: [{ id: 't1', function: { name: 'f' } }] }, + { role: 'tool', content: 'tool output', tool_call_id: 't1' }, + { role: 'user', content: 'now summarize' }, + { role: 'user', content: 'trailing' } + ], + MODEL + ) + + const merged = history[history.length - 1]?.userInputMessage + expect(merged?.content).toBe('now summarize') + expect(merged?.userInputMessageContext?.toolResults).toEqual([ + { content: [{ text: 'tool output' }], status: 'success', toolUseId: 't1' } + ]) + }) + + test('duplicate toolUseIds across the merged sides are deduplicated', () => { + const history = buildHistory( + [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'dup', content: 'winner' }] + }, + { role: 'tool', content: 'loser', tool_call_id: 'dup' }, + { role: 'user', content: 'trailing' } + ], + MODEL + ) + + expect(history[0]?.userInputMessage?.userInputMessageContext?.toolResults).toEqual([ + { content: [{ text: 'winner' }], status: 'success', toolUseId: 'dup' } + ]) + }) + + test('the surviving entry keeps its own modelId and origin', () => { + const history = buildHistory( + [ + { role: 'user', content: 'u1' }, + { role: 'user', content: 'u2' }, + { role: 'user', content: 'trailing' } + ], + MODEL + ) + + expect(history[0]?.userInputMessage?.modelId).toBe(MODEL) + expect(history[0]?.userInputMessage?.origin).toBe('AI_EDITOR') + }) + + test('merged images are capped at the per-turn API limit and the drop is recorded', () => { + const imagePart = (n: number) => ({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: `img${n}` } + }) + const history = buildHistory( + [ + { + role: 'user', + content: [{ type: 'text', text: 'batch one' }, ...[1, 2, 3].map(imagePart)] + }, + { role: 'user', content: [{ type: 'text', text: 'batch two' }, ...[4, 5].map(imagePart)] }, + { role: 'user', content: 'trailing' } + ], + MODEL + ) + + const merged = history[0]?.userInputMessage + expect(merged?.images).toHaveLength(4) + expect(merged?.content).toContain('batch one') + expect(merged?.content).toContain('batch two') + expect(merged?.content).toContain('1 image(s) omitted due to API limits') + }) +}) + +describe('no assistant turn carries a synthesized conversation marker', () => { + const shapes: Array<{ label: string; body: any }> = [ + { + label: 'first turn with a system prompt', + body: { system: 'SYS', messages: [{ role: 'user', content: 'hi' }] } + }, + { + label: 'user turn after a tool loop', + body: { messages: [...toolChainMsgs(2), { role: 'user', content: 'now summarize' }] } + }, + { label: 'tool turn closing a 5-pair chain', body: { messages: toolChainMsgs(5) } }, + { + label: 'user text interleaved with tool results', + body: { + messages: [ + { + role: 'assistant', + content: 'call', + tool_calls: [{ id: 't1', function: { name: 'f' } }] + }, + { role: 'user', content: 'extra instruction' }, + { role: 'tool', content: 'result', tool_call_id: 't1' }, + { role: 'user', content: 'wrap up' } + ] + } + } + ] + + for (const shape of shapes) { + test(`${shape.label}: every assistant turn is marker-free`, () => { + const state = transformToSdkRequest(shape.body, MODEL, auth).conversationState + for (const content of assistantContents(state)) { + expect(content).not.toContain('conversation continues') + } + }) + + test(`${shape.label}: history alternates strictly`, () => { + const state = transformToSdkRequest(shape.body, MODEL, auth).conversationState + expect(alternationViolations(state.history ?? [])).toEqual([]) + }) + } + + test('a 6-pair tool chain needs no synthesized assistant separator at all', () => { + const state = transformToSdkRequest( + { messages: toolChainMsgs(6) }, + MODEL, + auth + ).conversationState + expect(separatorTurnCount(state.history ?? [])).toBe(0) + }) + + test('the marker survives only where it belongs: the currentMessage user turn', () => { + const assistantFinal = transformToSdkRequest( + { + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: 'partial answer' } + ] + }, + MODEL, + auth + ).conversationState + expect(assistantFinal.currentMessage.userInputMessage?.content).toBe(CONVERSATION_MARKER) + for (const content of assistantContents(assistantFinal)) { + expect(content).not.toContain('conversation continues') + } + + const emptyUserFinal = transformToSdkRequest( + { messages: [{ role: 'user', content: '' }] }, + MODEL, + auth + ).conversationState + expect(emptyUserFinal.currentMessage.userInputMessage?.content).toBe(CONVERSATION_MARKER) + for (const content of assistantContents(emptyUserFinal)) { + expect(content).not.toContain('conversation continues') + } + }) +}) diff --git a/src/infrastructure/transformers/history-builder.ts b/src/infrastructure/transformers/history-builder.ts index 6a58a2a..ae9860f 100644 --- a/src/infrastructure/transformers/history-builder.ts +++ b/src/infrastructure/transformers/history-builder.ts @@ -1,5 +1,6 @@ import { KIRO_CONSTANTS } from '../../constants.js' import { + MAX_KIRO_IMAGES, convertImagesToKiroFormat, extractAllImages, extractTextFromParts @@ -96,6 +97,59 @@ export function collapseAgenticLoops(history: CodeWhispererMessage[]): CodeWhisp return result } +type KiroUserTurn = NonNullable + +/** + * Fold a user-shaped turn into the preceding history entry when that entry is also + * user-shaped, reporting whether it was absorbed. + * + * Kiro expects `history` to alternate user/assistant, yet a transcript legitimately + * places two user-shaped entries side by side: a text message followed by tool + * results, tool results followed by a fresh instruction, or two client-side user + * messages. Synthesizing an assistant turn to separate them puts + * system-directive-looking text into assistant content, which the model reads as an + * in-context example of its own voice and reproduces verbatim instead of issuing a + * real tool call. Merging removes the need for a separator at the root, matching + * jwadow/kiro-gateway PR #238 and Kiro-Go's adjacent tool-result merge. + * + * The surviving entry keeps its position and its own `modelId`/`origin`, so ordering + * and wire metadata are untouched. Images are capped at the converter's own per-turn + * limit, because two merged turns can otherwise exceed what the API accepts. + */ +function mergeIntoPreviousUserTurn( + history: CodeWhispererMessage[], + incoming: KiroUserTurn +): boolean { + const previous = history[history.length - 1]?.userInputMessage + if (!previous) return false + + if (incoming.content) { + previous.content = previous.content + ? `${previous.content}\n\n${incoming.content}` + : incoming.content + } + + const incomingResults = incoming.userInputMessageContext?.toolResults + if (incomingResults && incomingResults.length > 0) { + const context = (previous.userInputMessageContext ??= {}) + context.toolResults = deduplicateToolResults([ + ...(context.toolResults ?? []), + ...incomingResults + ]) + } + + if (incoming.images && incoming.images.length > 0) { + const combined = [...(previous.images ?? []), ...incoming.images] + previous.images = combined.slice(0, MAX_KIRO_IMAGES) + const omitted = combined.length - previous.images.length + if (omitted > 0) { + previous.content = `${previous.content}\n\n[${omitted} image(s) omitted due to API limits]` + } + } + + return true +} + export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessage[] { let history: CodeWhispererMessage[] = [] const fallbackByAssistant = new Map< @@ -137,10 +191,7 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag } if (trs.length) uim.userInputMessageContext = { toolResults: deduplicateToolResults(trs) } - const prev = history[history.length - 1] - if (prev && prev.userInputMessage) - history.push({ assistantResponseMessage: { content: '[system: conversation continues]' } }) - history.push({ userInputMessage: uim }) + if (!mergeIntoPreviousUserTurn(history, uim)) history.push({ userInputMessage: uim }) } else if (m.role === 'tool') { const trs: any[] = [] if (m.tool_results) { @@ -157,17 +208,14 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag toolUseId: m.tool_call_id }) } - const prev = history[history.length - 1] - if (prev && prev.userInputMessage) - history.push({ assistantResponseMessage: { content: '[system: conversation continues]' } }) - history.push({ - userInputMessage: { - content: '', - modelId: resolved, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - userInputMessageContext: { toolResults: deduplicateToolResults(trs) } - } - }) + const toolTurn: KiroUserTurn = { + content: '', + modelId: resolved, + origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, + userInputMessageContext: { toolResults: deduplicateToolResults(trs) } + } + if (!mergeIntoPreviousUserTurn(history, toolTurn)) + history.push({ userInputMessage: toolTurn }) } else if (m.role === 'assistant') { const reconstructed = reconstructAssistantResponse(m, resolved, { recoverReasoning: i >= loopStart, diff --git a/src/plugin/image-handler.ts b/src/plugin/image-handler.ts index 6ff468b..a946890 100644 --- a/src/plugin/image-handler.ts +++ b/src/plugin/image-handler.ts @@ -3,7 +3,7 @@ interface UnifiedImage { data: string } -const MAX_KIRO_IMAGES = 4 +export const MAX_KIRO_IMAGES = 4 const MAX_KIRO_IMAGE_BYTES = 3_750_000 interface KiroImage { diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 37166f8..34eda29 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -149,8 +149,14 @@ function buildCodeWhispererRequest( curContent = '[system: conversation continues]' } else { const prev = history[history.length - 1] + // `currentMessage` is always a user turn, so a trailing user-shaped history entry + // breaks Kiro's alternation. Unlike two adjacent history turns this pair cannot be + // merged: they straddle the history/currentMessage boundary, and the trailing entry + // is usually the injected system-prompt turn, which has to stay in history. An + // empty assistant turn is the official tool-only shape and, unlike a placeholder + // string, gives the model no text of its own voice to reproduce. if (prev && !prev.assistantResponseMessage) - history.push({ assistantResponseMessage: { content: '[system: conversation continues]' } }) + history.push({ assistantResponseMessage: { content: '' } }) if (curMsg.role === 'tool') { if (curMsg.tool_results) { for (const tr of curMsg.tool_results) From d9fcc3b06030c2d73b9317826e16c4d0409a5243 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 20:53:48 +0800 Subject: [PATCH 20/21] =?UTF-8?q?docs(probes):=20=E6=9B=B4=E6=AD=A3?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E5=A1=AB=E5=85=85=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E5=AE=9E=E7=8E=B0=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../probes/PREMATURE-STOP-INVESTIGATION.md | 242 ++++++++++-------- scripts/probes/README.md | 11 +- 2 files changed, 141 insertions(+), 112 deletions(-) diff --git a/scripts/probes/PREMATURE-STOP-INVESTIGATION.md b/scripts/probes/PREMATURE-STOP-INVESTIGATION.md index 1b2fee0..2f30883 100644 --- a/scripts/probes/PREMATURE-STOP-INVESTIGATION.md +++ b/scripts/probes/PREMATURE-STOP-INVESTIGATION.md @@ -35,6 +35,15 @@ > [§15](#15-官方客户端行为的一手证据补录)**:Kiro CLI 2.12.0 的实时 trace 与 Kiro IDE 的连续请求体 > 证实 `content` 在当前回合和沉入历史后都保持 `""`,Smithy 模型本身也不要求非空。该节同时说明 > 它**只**独立佐证官方行为,因果数字仍来自本文自己的测量。 +> +> 🚩 **修复状态(本文最新事实,读全文前先看这一条):C5 方案已经落地。** 提交 +> `017e662`(`fix(request): 工具结果回合改用空 content 以避免模型提前结束回合`,PR #81)把 +> `src/infrastructure/transformers/history-builder.ts` 与 `src/plugin/request.ts` 两个填充点 +> 都改成了 `''`,落地记录见 [§14 已实施的修复](#14-已实施的修复)。因此**本文正文里凡是说 +> 「生产代码未改」「生产仍发 `'Tool results provided.'`」的句子,一律是写作当时的状态记录, +> 不是 HEAD 的状态**(§0 上面那句「生产代码本批仍然一行未改」、§7、§11、§13.7 都属于这一类)。 +> 想自己确认一行命令就够:`grep -rn 'Tool results provided' src` 现在只会命中两个测试 fixture, +> 没有任何生产代码。 同时有两个同样重要的否证结论: @@ -315,12 +324,12 @@ V5 是机制实验,不是生产复现。任何把「V5 = 95%」直接说成「 候选取值与实测: -| 取值 | turn 2 | turn 5 | 评价 | -| ---------------------------------------------------------------------------------- | ------ | ---------------- | ---------------------------------------------------------- | -| `'Tool results provided.'`(现状) | 16.0% | 0.0% | 现状 | -| `''` | 0.0% | **6.7%(更差)** | **不要用**:长任务上引入了新的停止 | +| 取值 | turn 2 | turn 5 | 评价 | +| ---------------------------------------------------------------------------------- | ------ | ---------------- | ----------------------------------------------------------- | +| `'Tool results provided.'`(现状) | 16.0% | 0.0% | 现状 | +| `''` | 0.0% | **6.7%(更差)** | **不要用**:长任务上引入了新的停止 | | `'[tool results]'` | 0.0% | 0.0% | ~~建议~~ **§11 复现失败:turn 2 实测 9.4%,不要按本行决策** | -| `'Tool results provided. Continue with the next tool call now; do not summarize.'` | 0.0% | 0.0% | 有效,但插件在向模型注入指令(见下);§11 复现成功 | +| `'Tool results provided. Continue with the next tool call now; do not summarize.'` | 0.0% | 0.0% | 有效,但插件在向模型注入指令(见下);§11 复现成功 | 推荐 `'[tool results]'`:它在两个位置都是 0,而且不向模型注入任何行为指令。V3 那种显式指令 同样有效,但让插件替用户往对话里塞「不要总结」这类命令,会和 OpenCode 自己的 system prompt @@ -493,16 +502,20 @@ bun run scripts/probes/ab-opencode/sanitize-runs.ts --in --out 🚩 **本节整体是历史记录。**「生产代码一行未改」说的是这一批当时的状态:`'[tool results]'` +> 确实从未上线。真正落地的是后来 §13 筛出的 C5(两处都置空),见 §14 与提交 `017e662`。 +> 本节下面的测量、p 值与决策表全部照原样保留,请把它们读成「当时掌握的证据」。 + ### 11.1 这一批测了什么 代码侧只做了一件事:给 `turn2-variant-probe.ts` 加了一个变体 **V10 = as-implemented**—— 把 `[tool results]` 同时写进**当前消息**和**history 里每一条带 `toolResults` 的用户回合**, 也就是修复落地后插件真正会发出的形状。零额度的 `DRY=1` 直接读出这个变体的作用面: -| 位置 | history 里带 toolResults 的回合数 | V10 相对 V2 | -| ------ | --------------------------------- | ----------- | +| 位置 | history 里带 toolResults 的回合数 | V10 相对 V2 | +| ------ | --------------------------------- | --------------------------------------------------------------------------------- | | turn 2 | **0** | **完全相同**(turn-2 的 history 是 `[user, assistant]`,根本没有 history 填充点) | -| turn 5 | **3** | 多改 3 条 history 回合(这才是新配置) | +| turn 5 | **3** | 多改 3 条 history 回合(这才是新配置) | **这一点很重要:turn 2 上 V10 与 V2 是同一个 payload。** 所以下面 turn-2 的数字既是「两处 都改」的测量,也是对 §4.1 里 V2 = 0/120 的**直接复现尝试**。 @@ -514,12 +527,12 @@ effort `high`、us-east-1)。捕获文件当天重新生成(`systemChars=215 同一会话、两个账号并行、误差全部从分母剔除: -| 变体 | 本次 n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 与 §4.1 是否复现 | -| -------------------------------- | ------ | ------- | --------- | ---------- | ---------------------- | ----------------------------- | -| **V0** 生产基线 | 255 | 50 | **19.6%** | 15.2–24.9% | — | **是**(16.0% → p = 0.42) | +| 变体 | 本次 n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 与 §4.1 是否复现 | +| --------------------------------- | ------ | ------- | --------- | ---------- | ---------------------- | ----------------------------- | +| **V0** 生产基线 | 255 | 50 | **19.6%** | 15.2–24.9% | — | **是**(16.0% → p = 0.42) | | **V10** = `'[tool results]'` 两处 | 256 | **24** | **9.4%** | 6.4–13.6% | **0.0011** | **否**(0/120 → p = 0.00013) | -| V1 `content` 空串 | 128 | 0 | 0.0% | 0.0–2.9% | 0.0000 | **是**(0/120) | -| V3 显式续跑指令 | 128 | 0 | 0.0% | 0.0–2.9% | 0.0000 | **是**(0/120) | +| V1 `content` 空串 | 128 | 0 | 0.0% | 0.0–2.9% | 0.0000 | **是**(0/120) | +| V3 显式续跑指令 | 128 | 0 | 0.0% | 0.0–2.9% | 0.0000 | **是**(0/120) | V10 的 24/256 来自**两个各自独立的 n=128 批次,各自恰好 12/128**(两批之间 p = 1.0)。 这不是单批噪声。 @@ -528,12 +541,12 @@ V10 的 24/256 来自**两个各自独立的 n=128 批次,各自恰好 12/128* ### 11.3 turn 5:两处都改是安全的,`''` 的陷阱也复现了 -| 变体 | 本次 n | stopped | 速率 | vs 本批 V0 的 Fisher p | 与 §4.3 是否复现 | -| --------------------------------- | ------ | ------- | -------- | ---------------------- | --------------------------- | -| V0 生产基线 | 128 | 0 | 0.0% | — | **是**(0/120) | -| **V10** = `'[tool results]'` 两处 | 128 | **0** | **0.0%** | **1.0000** | 首次测量:**不比基线差** | -| V1 `content` 空串 | 128 | 10 | **7.8%** | **0.0016(更差)** | **是**(6.7% → p = 0.81) | -| V3 显式续跑指令 | 128 | 0 | 0.0% | 1.0000 | **是**(0/119) | +| 变体 | 本次 n | stopped | 速率 | vs 本批 V0 的 Fisher p | 与 §4.3 是否复现 | +| --------------------------------- | ------ | ------- | -------- | ---------------------- | ------------------------- | +| V0 生产基线 | 128 | 0 | 0.0% | — | **是**(0/120) | +| **V10** = `'[tool results]'` 两处 | 128 | **0** | **0.0%** | **1.0000** | 首次测量:**不比基线差** | +| V1 `content` 空串 | 128 | 10 | **7.8%** | **0.0016(更差)** | **是**(6.7% → p = 0.81) | +| V3 显式续跑指令 | 128 | 0 | 0.0% | 1.0000 | **是**(0/119) | 所以「两处都改」这个从未被测过的配置在长任务形态下是**安全**的——这一条如实回答了任务提出 的问题。但它在 turn 5 无法证明有效性:基线本身是 0/128,本 N 下**任何**结果(包括 0)都达不到 @@ -554,18 +567,21 @@ p < 0.05 的「下降」(analyzer 自己会打印这句话)。 `''` 和显式指令之间选它,靠的正是「turn 2 = 0%、turn 5 = 0%」这个双 0 的理由——这个理由现在 只剩一半。把 9.4% 的行为当成「已修复」发布出去,会让用户以为问题解决了。 -因此:**生产代码未改动,既有测试未改动,`verify-v0.ts` 依然与生产逐字段一致**(它的 -`FILLER = 'Tool results provided.'` 仍是生产值,不需要加任何「pre-fix 基线」说明)。 +因此(**以下是本批当时的处置,不是 HEAD 的状态**):**生产代码未改动,既有测试未改动, +`verify-v0.ts` 依然与生产逐字段一致**(它的 `FILLER = 'Tool results provided.'` 当时仍是生产值, +不需要加任何「pre-fix 基线」说明)。修复在后来的 `017e662` 里按 C5 落地之后,这句话的最后半句 +就不再成立了:`FILLER` 从那时起记录的是**修复前基线**,`verify-v0.ts` 会在 +`currentMessage.content` 一项上显示不一致,而那正是预期结果(见 §13.7 验证顺序第 2 步与 §14)。 ### 11.5 现在需要决策的是什么 -| 选项 | turn 2 | turn 5 | 代价 | -| --------------------------------------------------- | ------------ | ------------ | ----------------------------------------------------------------- | -| 保持现状 | 19.6% | 0.0% | 缺陷仍在 | -| `'[tool results]'` | 9.4% | 0.0% | 只减半,不消除;宣称「已修复」会误导 | -| `''` | 0.0% | **7.8%** | 两次独立批次都在长任务上引入新停止,**仍然不能用** | -| 显式续跑指令(§4.1 V3) | **0.0%** | **0.0%** | 插件替用户向模型注入行为指令——**产品决策**,用户此前明确未要求 | -| 再找新取值 | 未测 | 未测 | 需要新一轮 n≥120 的二分;本批已证明「机器标签」这条思路并非必然为 0 | +| 选项 | turn 2 | turn 5 | 代价 | +| ----------------------- | -------- | -------- | ------------------------------------------------------------------- | +| 保持现状 | 19.6% | 0.0% | 缺陷仍在 | +| `'[tool results]'` | 9.4% | 0.0% | 只减半,不消除;宣称「已修复」会误导 | +| `''` | 0.0% | **7.8%** | 两次独立批次都在长任务上引入新停止,**仍然不能用** | +| 显式续跑指令(§4.1 V3) | **0.0%** | **0.0%** | 插件替用户向模型注入行为指令——**产品决策**,用户此前明确未要求 | +| 再找新取值 | 未测 | 未测 | 需要新一轮 n≥120 的二分;本批已证明「机器标签」这条思路并非必然为 0 | `V4`(补 `` 前缀)本批未重测,其副作用仍未评估(见 §10.4)。 @@ -575,14 +591,14 @@ p < 0.05 的「下降」(analyzer 自己会打印这句话)。 ### 11.6 这一批的额度与卫生 -| 批次 | 真实 Kiro 调用 | -| ----------------------------- | -------------- | -| turn 2:V0+V10(两轮) | 512 | -| turn 2:V3+V1 | 256 | -| turn 5:V0+V10 | 256 | -| turn 5:V1 / V3 | 256 | -| `capture-inbound.ts` × 2 | **0** | -| **合计** | **1280** | +| 批次 | 真实 Kiro 调用 | +| ------------------------ | -------------- | +| turn 2:V0+V10(两轮) | 512 | +| turn 2:V3+V1 | 256 | +| turn 5:V0+V10 | 256 | +| turn 5:V1 / V3 | 256 | +| `capture-inbound.ts` × 2 | **0** | +| **合计** | **1280** | - 两个账号各自 pin 死(`KIRO_PROBE_ACCOUNT`),headroom 9999 / 9947,从不自动选号。 - `kiro.db` 全程只读;本批结束后库里 `used_count` 仍是 **1 / 53**,而 usage 接口读到的是 @@ -626,13 +642,13 @@ KIRO_PROBE_ACCOUNT='you@example.com' CONFIRM=1 \ 回答这个问题,需要先找到一个 history 填充点存在、基线又非 0 的位置。 4. **仍然只在一个 fixture 上测过**(§10.1 原样适用)。 - --- ## 12. 官方值调研:`userInputMessage.content` 该填什么 本节与 §13 是**第三个批次**,与 §1–§10(调查)、§11(复现)都是独立批次。这一批先做文献/源码 -调研,再据此设计候选并实测。**生产代码依然一行未改**(`git diff cf4c55f..HEAD -- src/` 为空)。 +调研,再据此设计候选并实测。**这一批结束时生产代码依然一行未改**(当时 `git diff cf4c55f..HEAD -- src/` +为空);真正的落地发生在本节之后的 `017e662`,见 §14。 ### 12.1 结论先说:官方值是空串 `""` @@ -671,6 +687,7 @@ KIRO_PROBE_ACCOUNT='you@example.com' CONFIRM=1 \ `d-kuro/kirocc` 复刻了这个形态。注意一个常见误读:这对回合里带 `toolResults` 的那条用户 消息**内容并不短**(1112 字符的工作区上下文);真正为空的是**助手**回合的 `content` 和 **当前**消息的 `content`。 + - **Q CLI 有一条条件规则**:当工具结果回合会成为历史里的第一条、或前一条助手回合不是 `tool_use` 时,它把工具结果**文本搬进 `content`**(`replace_content_with_tool_use_results`, 注释写的是 _"This is required to avoid validation errors."_),该路径的空内容兜底值是 @@ -698,15 +715,15 @@ KIRO_PROBE_ACCOUNT='you@example.com' CONFIRM=1 \ 每个变体都由**真实** `transformToSdkRequest` 生成 V0,再只改预期的那一个元素(`verify-v0.ts` 立下的规矩;本批当天重跑 `verify-v0.ts`,V0 在每个可比字段上仍与生产一致)。 -| 候选 | 改了什么 | 相对谁只差一处 | -| ------- | -------------------------------------------------------------------------- | -------------- | -| **C1** | `''`(当前消息)**+** Kiro 预热回合前插到 history | V1 | -| **C1b** | `''` **+** 预热回合,且按真实客户端的**完整回合布局**(系统提示词单独成 history[0],用户提示词跟在预热之后) | V1 | -| **C2** | `''` **+** 撤销 `collapseAgenticLoops` | V1 | -| **C3** | Q CLI 时间戳上下文块(真实本地 RFC3339),当前消息与 history 填充点都用 | V0 | -| **C4** | `'(tool result above)'`(TsinHzl),两处都用 | V0 | -| **C5** | **`''`,每一个工具结果填充点都置空**(当前消息 + history) | V1 | -| V0 / V1 | 同批次对照:生产基线 / 只把**当前消息**置空 | — | +| 候选 | 改了什么 | 相对谁只差一处 | +| ------- | ------------------------------------------------------------------------------------------------------------ | -------------- | +| **C1** | `''`(当前消息)**+** Kiro 预热回合前插到 history | V1 | +| **C1b** | `''` **+** 预热回合,且按真实客户端的**完整回合布局**(系统提示词单独成 history[0],用户提示词跟在预热之后) | V1 | +| **C2** | `''` **+** 撤销 `collapseAgenticLoops` | V1 | +| **C3** | Q CLI 时间戳上下文块(真实本地 RFC3339),当前消息与 history 填充点都用 | V0 | +| **C4** | `'(tool result above)'`(TsinHzl),两处都用 | V0 | +| **C5** | **`''`,每一个工具结果填充点都置空**(当前消息 + history) | V1 | +| V0 / V1 | 同批次对照:生产基线 / 只把**当前消息**置空 | — | **C5 是本批新增的候选,不在任务列的四个里,但它是必须加的**:`V1` 只清空当前消息,而一个 原生发送官方值的客户端在历史里也是 `""`(今天的当前消息就是明天的历史条目)。不测 C5 就无法 @@ -749,28 +766,28 @@ V1 代表;Stage 2 仍然给 C5 单独跑满 turn 2 的 2×128,不靠这条 turn 2(`results/screen-turn2/`,对照 **V0 = 12/64 = 18.8%,落在既有区间内 → 批次有效**): -| 变体 | n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 判定 | -| --------- | --- | ------- | --------- | ---------- | ---------------------- | --------------------------- | -| V0 基线 | 64 | 12 | 18.8% | 11.1–30.0% | — | 对照有效 | -| V1 对照 | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 复现 | -| **C1** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛 | -| **C1b** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛 | -| **C3** | 30 | **11** | **36.7%** | 21.9–54.5% | 0.0742 | **淘汰(比生产基线还差)** | -| **C4** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛(但见 §13.5 的冒烟记录) | +| 变体 | n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 判定 | +| ------- | --- | ------- | --------- | ---------- | ---------------------- | ----------------------------- | +| V0 基线 | 64 | 12 | 18.8% | 11.1–30.0% | — | 对照有效 | +| V1 对照 | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 复现 | +| **C1** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛 | +| **C1b** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛 | +| **C3** | 30 | **11** | **36.7%** | 21.9–54.5% | 0.0742 | **淘汰(比生产基线还差)** | +| **C4** | 30 | 0 | 0.0% | 0.0–11.4% | 0.0083 | 过筛(但见 §13.5 的冒烟记录) | turn 5(`results/screen-turn5/`,两批合并;对照 **V0 = 0/128、V1 = 7/128 = 5.5%(p = 0.0144) → 批次能测到陷阱,有效**): -| 变体 | n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 判定 | -| ------- | --- | ------- | --------- | --------- | ---------------------- | ------------------- | -| V0 基线 | 128 | 0 | 0.0% | 0.0–2.9% | — | 对照有效 | -| V1 对照 | 128 | 7 | 5.5% | 2.7–10.9% | 0.0144(更差) | **陷阱第三次复现** | -| **C1** | 30 | **1** | 3.3% | 0.6–16.7% | 0.1899 | **淘汰** | -| **C1b** | 30 | **3** | **10.0%** | 3.5–25.6% | 0.0063 | **淘汰** | -| **C2** | 53 | **4** | **7.5%** | 3.0–17.9% | 0.0068 | **淘汰** | +| 变体 | n | stopped | 速率 | 95% CI | vs 本批 V0 的 Fisher p | 判定 | +| ------- | --- | ------- | --------- | --------- | ---------------------- | ------------------------- | +| V0 基线 | 128 | 0 | 0.0% | 0.0–2.9% | — | 对照有效 | +| V1 对照 | 128 | 7 | 5.5% | 2.7–10.9% | 0.0144(更差) | **陷阱第三次复现** | +| **C1** | 30 | **1** | 3.3% | 0.6–16.7% | 0.1899 | **淘汰** | +| **C1b** | 30 | **3** | **10.0%** | 3.5–25.6% | 0.0063 | **淘汰** | +| **C2** | 53 | **4** | **7.5%** | 3.0–17.9% | 0.0068 | **淘汰** | | **C3** | 30 | **1** | 3.3% | 0.6–16.7% | 0.1899 | **淘汰**(turn 2 已淘汰) | -| **C4** | 30 | 0 | 0.0% | 0.0–11.4% | 1.0000 | 过筛 | -| **C5** | 30 | 0 | 0.0% | 0.0–11.4% | 1.0000 | 过筛 | +| **C4** | 30 | 0 | 0.0% | 0.0–11.4% | 1.0000 | 过筛 | +| **C5** | 30 | 0 | 0.0% | 0.0–11.4% | 1.0000 | 过筛 | **所以「预热回合」这条最被看好的假设被否证了**:C1 与 C1b 都在 turn 5 掉了。前插一对示范 性的工具结果回合**不能**救回空串在长任务上的不稳定。C2 也否证了「空串 × 撤销折叠」这个 @@ -780,23 +797,23 @@ turn 5(`results/screen-turn5/`,两批合并;对照 **V0 = 0/128、V1 = 7/1 turn 2(`results/confirm-turn2/`,两批:账号 A 然后账号 B): -| 变体 | 批 1 | 批 2 | 合计 | 速率 | 95% CI | vs 本批 V0 的 Fisher p | -| ------ | -------- | -------- | ------- | -------- | --------- | ---------------------- | -| V0 | 16/64 | 13/64 | 29/128 | 22.7% | 16.3–30.6% | — | -| **C5** | **0/128** | **0/128** | **0/256** | **0.0%** | 0.0–1.5% | **0.0000** | -| C4 | 2/128 | — | 2/128 | 1.6% | 0.4–5.5% | 0.0000 | +| 变体 | 批 1 | 批 2 | 合计 | 速率 | 95% CI | vs 本批 V0 的 Fisher p | +| ------ | --------- | --------- | --------- | -------- | ---------- | ---------------------- | +| V0 | 16/64 | 13/64 | 29/128 | 22.7% | 16.3–30.6% | — | +| **C5** | **0/128** | **0/128** | **0/256** | **0.0%** | 0.0–1.5% | **0.0000** | +| C4 | 2/128 | — | 2/128 | 1.6% | 0.4–5.5% | 0.0000 | **C4 在这里死掉**:2/128。它确实是一个真实的大幅下降(22.7% → 1.6%,p < 1e-4),但按预先声明 的「出现任何一次停止即淘汰」它不过关 —— 而这正是 §11 的教训:只减半的东西不能叫修好。 turn 5(`results/confirm-turn5/`,两批:账号 B 然后账号 A): -| 变体 | 批 1 | 批 2 | 合计 | 速率 | 95% CI | vs V0 的 p | vs V1 的 p | -| ------ | -------- | -------- | ------- | -------- | --------- | ---------- | ---------- | -| V0 | 0/64 | **1/64** | 1/128 | 0.8% | 0.1–4.3% | — | 0.0027 | -| V1 | 3/64 | 9/64 | 12/128 | **9.4%** | 5.4–15.7% | 0.0027(更差) | — | -| **C5** | **0/128** | **0/128** | **0/256** | **0.0%** | 0.0–1.5% | 0.3333(不更差) | **0.0000** | -| C4 | 0/128 | — | 0/128 | 0.0% | 0.0–2.9% | 1.0000 | 0.0004 | +| 变体 | 批 1 | 批 2 | 合计 | 速率 | 95% CI | vs V0 的 p | vs V1 的 p | +| ------ | --------- | --------- | --------- | -------- | --------- | ---------------- | ---------- | +| V0 | 0/64 | **1/64** | 1/128 | 0.8% | 0.1–4.3% | — | 0.0027 | +| V1 | 3/64 | 9/64 | 12/128 | **9.4%** | 5.4–15.7% | 0.0027(更差) | — | +| **C5** | **0/128** | **0/128** | **0/256** | **0.0%** | 0.0–1.5% | 0.3333(不更差) | **0.0000** | +| C4 | 0/128 | — | 0/128 | 0.0% | 0.0–2.9% | 1.0000 | 0.0004 | `empty200` 在本批全部 1922 次试验里仍然是 **0**。全部批次错误数为 0,唯一的例外是 §13.5 记的 那个令牌过期批次。 @@ -832,11 +849,11 @@ turn 5(`results/confirm-turn5/`,两批:账号 B 然后账号 A): 同批次数据直接支持这个解释,而且是本批里最干净的一组对比: -| turn 5,同两个批次内 | 当前消息 | history 填充点 | stopped | -| -------------------- | -------- | ----------------------- | --------- | -| V0 | 句子 | 句子 | 1/128 | -| **V1** | **空** | **句子(不一致)** | **12/128 = 9.4%** | -| **C5** | **空** | **空(一致,= 官方形态)** | **0/256** | +| turn 5,同两个批次内 | 当前消息 | history 填充点 | stopped | +| -------------------- | -------- | -------------------------- | ----------------- | +| V0 | 句子 | 句子 | 1/128 | +| **V1** | **空** | **句子(不一致)** | **12/128 = 9.4%** | +| **C5** | **空** | **空(一致,= 官方形态)** | **0/256** | `C5` vs `V1`:**p < 1e-4**(`--baseline V1` 可重算)。也就是说 §11 那条「`''` 是陷阱、不能用」的 结论**在它自己的测量范围内仍然正确**,但它测的是一个**没有任何客户端会发出的中间态**。把 @@ -853,14 +870,23 @@ turn 5(`results/confirm-turn5/`,两批:账号 B 然后账号 A): - **C1/C1b 失败**:光靠前面示范一次,压不过后面每一条历史回合都在说整句英文这个更近、更强的 反例。 -### 13.7 修复提案(本任务**不实现**) +### 13.7 修复提案(写作时**未实现**;现已落地于 `017e662`) + +> 🚩 **本节写于提案阶段,标题里的「不实现」只对写作当时成立。** 这份提案后来被完整采纳并合并: +> 提交 `017e662`(`fix(request): 工具结果回合改用空 content 以避免模型提前结束回合`,PR #81)把两个 +> 填充点都改成了 `''`。落地说明见 [§14 已实施的修复](#14-已实施的修复)。下面这张表是**修复前的 +> 代码状态(pre-fix 基线)**:文件路径仍然正确,但行号是提案当时的行号 —— 后续提交(含把相邻同角色 +> 回合合并的改动)已经让它们与 HEAD 不再对应。**表格按原样保留是为了记录当时改了什么,不要按它的 +> 行号去定位今天的代码**,改动内容本身请以 §14 和 `git show 017e662` 为准。 **唯一改动点:把两个填充点都改成空串**,也就是 C5。 -| 文件 | 行 | 现状 | 改成 | -| -------------------------------------------------------- | --- | --------------------------------------------------------------------------- | -------------------------------------------------------- | -| `src/infrastructure/transformers/history-builder.ts` | 155 | `content: 'Tool results provided.',` | `content: '',` | -| `src/plugin/request.ts` | 184 | `curContent = curTrs.length ? 'Tool results provided.' : '[system: conversation continues]'` | `if (!curContent && !curTrs.length) curContent = '[system: conversation continues]'` | +修复前的代码状态与提案改法(行号为提案当时,非 HEAD): + +| 文件 | 行 | 修复前 | 提案改成 | +| ---------------------------------------------------- | --- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `src/infrastructure/transformers/history-builder.ts` | 155 | `content: 'Tool results provided.',` | `content: '',` | +| `src/plugin/request.ts` | 184 | `curContent = curTrs.length ? 'Tool results provided.' : '[system: conversation continues]'` | `if (!curContent && !curTrs.length) curContent = '[system: conversation continues]'` | `request.ts` 那一行的写法要注意:现在是 `if (!curContent) curContent = curTrs.length ? A : B`。 正确的改法是**把有工具结果的那一支整个去掉**(让 content 保持空),而不是给它赋一个 `''`—— @@ -876,11 +902,11 @@ AWS 期望的 wire 值,它是「不放填充文本」这件事本身;给它 **需要有意更新的既有测试(唯一允许改测试的理由,且必须是「跟随实现改期望值」):** -| 位置 | 性质 | 处理 | -| ------------------------------------------ | ------------------------------------------------------------- | --------------------------------- | -| `src/__tests__/history-builder.test.ts:100` | **真断言**:`expect(toolTurn?.content).toBe('Tool results provided.')` | 改成 `toBe('')` | -| `src/__tests__/history-builder.test.ts:152` | 输入 fixture(`collapseAgenticLoops` 的入参),不断言填充文本 | 可不改;若为保持真实性一并改,属纯 cosmetic | -| `src/__tests__/message-transformer.test.ts:202` | 输入 fixture(`sanitizeHistory` 的入参),不断言填充文本 | 同上 | +| 位置 | 性质 | 处理 | +| ----------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------- | +| `src/__tests__/history-builder.test.ts:100` | **真断言**:`expect(toolTurn?.content).toBe('Tool results provided.')` | 改成 `toBe('')` | +| `src/__tests__/history-builder.test.ts:152` | 输入 fixture(`collapseAgenticLoops` 的入参),不断言填充文本 | 可不改;若为保持真实性一并改,属纯 cosmetic | +| `src/__tests__/message-transformer.test.ts:202` | 输入 fixture(`sanitizeHistory` 的入参),不断言填充文本 | 同上 | **已经查过、可以放心的一点**:没有任何清洗逻辑会因为 `content` 为空而丢掉一条 `userInputMessage`。`history-builder.ts:165` 与 `request.ts:123/138` 的真值判断都只作用在 @@ -943,18 +969,18 @@ KIRO_PROBE_ACCOUNT='you@example.com' CONFIRM=1 \ ### 13.9 本批的额度与卫生 -| 批次 | 真实 Kiro 调用 | -| --------------------------------------------- | -------------- | -| Stage 1 turn 2(`screen-turn2/`) | 214 | -| Stage 1 turn 5 第一批(令牌中途过期) | 308 | -| Stage 1 turn 5 补跑(`screen-turn5/` 第二个文件) | 248 | -| Stage 2 turn 2 批 1 / 批 2 | 320 / 192 | -| Stage 2 turn 5 批 1 / 批 2 | 384 / 256 | -| 已提交批次小计 | **1922** | -| 冒烟(n=2 × 8 格,产物在 `/tmp`,未提交) | 16 | -| 一次参数写错后立刻掐掉的启动(未落盘) | 约 26(估计值,日志已被覆盖,无法精确点数) | -| `capture-inbound.ts` × 2、`verify-v0.ts` × 1 | **0** | -| **合计** | **约 1964** | +| 批次 | 真实 Kiro 调用 | +| ------------------------------------------------- | ------------------------------------------- | +| Stage 1 turn 2(`screen-turn2/`) | 214 | +| Stage 1 turn 5 第一批(令牌中途过期) | 308 | +| Stage 1 turn 5 补跑(`screen-turn5/` 第二个文件) | 248 | +| Stage 2 turn 2 批 1 / 批 2 | 320 / 192 | +| Stage 2 turn 5 批 1 / 批 2 | 384 / 256 | +| 已提交批次小计 | **1922** | +| 冒烟(n=2 × 8 格,产物在 `/tmp`,未提交) | 16 | +| 一次参数写错后立刻掐掉的启动(未落盘) | 约 26(估计值,日志已被覆盖,无法精确点数) | +| `capture-inbound.ts` × 2、`verify-v0.ts` × 1 | **0** | +| **合计** | **约 1964** | - 四个有余量的账号(headroom 9999 / 9947 / 500 / 293)全部**显式 pin 死** (`KIRO_PROBE_ACCOUNT`),从不自动选号;探针的 headroom 拒绝与 `CONFIRM=1` 守卫都保留。 @@ -1072,14 +1098,14 @@ chat_cli_v2::api_client: Sending conversation: ConversationState { 同一次 IDE 会话中相隔 16 秒的两份捕获请求体,均由本文作者亲自解析: -| 捕获 | history 中携带 `toolResults` 的回合 | 当前消息 | -| --- | --- | --- | -| 第一份 | 无 | `content=''`,1 个 toolResult,29 个工具 | -| 第二份(+16s) | **1 个,且其 `content` 仍为 `''`** | `content=''`,1 个 toolResult,29 个工具 | +| 捕获 | history 中携带 `toolResults` 的回合 | 当前消息 | +| -------------- | ----------------------------------- | ---------------------------------------- | +| 第一份 | 无 | `content=''`,1 个 toolResult,29 个工具 | +| 第二份(+16s) | **1 个,且其 `content` 仍为 `''`** | `content=''`,1 个 toolResult,29 个工具 | 这是决定性的一条:它证明真实客户端在该回合**沉入历史之后**依然保持空值,而不只是在当前回合 -为空。这就是对本项目两处修复(§13.7 / §14)所依据推理的一手确认——*今天的当前消息就是明天的 -历史条目*——此前我们只是推断。另外注意 IDE 每次续跑都会重发完整的 29 个工具列表。 +为空。这就是对本项目两处修复(§13.7 / §14)所依据推理的一手确认——_今天的当前消息就是明天的 +历史条目_——此前我们只是推断。另外注意 IDE 每次续跑都会重发完整的 29 个工具列表。 ### 15.4 这解释了为什么 C3 候选反而比基线更差 diff --git a/scripts/probes/README.md b/scripts/probes/README.md index 47a432c..8ee7367 100644 --- a/scripts/probes/README.md +++ b/scripts/probes/README.md @@ -27,8 +27,8 @@ Contents: without it at turn 5) and per-request `conversationId`. **A later replication batch (1280 more real calls, investigation §11) reproduced the baseline (19.6%, p = 0.42), the empty-string trap (7.8% at turn 5) and the explicit-instruction variant (0/128), but did NOT reproduce `'[tool results]'` at 0% - — it measured 24/256 = 9.4% there, in two independent batches of exactly 12/128. The fix was - therefore NOT implemented; production still sends `'Tool results provided.'`.** Do not cite the + — it measured 24/256 = 9.4% there, in two independent batches of exactly 12/128. That value was + therefore NOT implemented, and as of §11 production still sent `'Tool results provided.'`.** Do not cite the "three replacements all reach 0%" sentence without §11. **A third batch (1922 more real calls, investigation §12–§13) then found the answer by research first: the real Kiro IDE, the official `aws/amazon-q-developer-cli`, and eight independent @@ -39,8 +39,11 @@ Contents: turn 2 and 0/256 at turn 5, each as two independent n=128 batches. That also dissolves §11's apparent contradiction: `''` was only ever a trap when the current message was emptied while history kept the English sentence (V1 = 12/128 at turn 5 vs C5 = 0/256, p < 1e-4) — an - inconsistent state no real client emits. Production is STILL unchanged; §13.7 is the reviewable - proposal.** + inconsistent state no real client emits. §13.7 was the reviewable proposal, and it HAS SINCE + BEEN IMPLEMENTED in commit `017e662` (`fix(request): 工具结果回合改用空 content 以避免模型提前结束回合`, + #81): both filler sites now send `''`, so the sentence "production still sent + `'Tool results provided.'`" above is HISTORICAL — it describes the state during §11, not HEAD. + Verify with `grep -rn 'Tool results provided' src` (expects zero non-test hits).** `capture-inbound.ts` is worth knowing about on its own: it records the exact OpenAI-shaped body the plugin's custom `fetch` receives, for **zero** quota, by standing a local mock where the plugin normally sits — which is the only way to see the From 218dd48c487558b2f0b234cb2e366af97a997dd2 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 21:03:34 +0800 Subject: [PATCH 21/21] =?UTF-8?q?docs(agents):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=B5=81=E6=81=A2=E5=A4=8D=E3=80=81=E8=A7=82=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=B1=A1=E6=9F=93=E4=BF=AE=E5=A4=8D=E5=88=B0?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 207 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 171 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f5c317..00a804f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ opencode SDK call **Post-200 stream-iteration failures** are a distinct path from the HTTP-error path above. Once `client.send()` resolves with HTTP 200 the response may still -fail while the SDK event stream is *iterated*. `ResponseHandler.handleSdkSuccess` +fail while the SDK event stream is _iterated_. `ResponseHandler.handleSdkSuccess` wraps the raw SDK iterator and rethrows only its `next()` errors as `SdkEventStreamIterationError` (`src/core/request/stream-error.ts:8`) — transform, serialization, and Response-construction errors are NOT wrapped and are never @@ -84,9 +84,12 @@ that typed error and: attempts. Attempt 1's retry reuses the current account; attempt 2 prefers a healthy alternative via `AccountSelector.selectAlternativeAccount` (account-selector.ts:73). Backoff is 250/500ms base + 0–25% jitter. -- **After output has been emitted in live-stream mode** — never re-calls the SDK - (no replay of content/tool calls). The stream ends and the failure surfaces as - `UpstreamUnexpectedError` with `emittedOutput: true`. +- **After output has been emitted in live-stream mode** — behavior depends on + `stream_recovery_mode` (`off` | `reasoning_restart` | `exact_replay`, default + `off`, env `KIRO_STREAM_RECOVERY_MODE`). Under `off` the SDK is never re-called: + the stream ends and the failure surfaces as `UpstreamUnexpectedError` with + `emittedOutput: true`. Under the two recovery modes the SDK _is_ re-called — + see "Live stream recovery" below. - **With `stream_buffer_until_complete` enabled** — consumes the entire transformed SSE response before exposing any chunk to OpenCode. An iterator failure at any point therefore remains a pre-delivery failure and can be @@ -95,7 +98,10 @@ that typed error and: range 1-10) controls the bounded retry count. - **On exhaustion** — returns a structured HTTP 503 via `UpstreamUnexpectedError.toResponse()`: - `{"retryable":true,"phase":"stream","emittedOutput":false,"code":"UPSTREAM_UNEXPECTED"}`. + `{"retryable":true,"phase":"stream","emittedOutput":false,"code":"UPSTREAM_UNEXPECTED"}`, + with `Retry-After: 2`. That header is load-bearing: opencode's own retry logic + applies its 2^31 cap (not the 30s no-headers cap) to any error carrying response + headers, so a 503 without it grows host backoff unboundedly. Live output uses a pull-driven `ReadableStream` with `highWaterMark: 0`; buffered recovery mode consumes that same transformed stream to completion and then @@ -117,6 +123,94 @@ A per-account **attempt epoch** plus `UsageTracker.syncUsage(..., isValid)` prevents a stale (superseded) stream from committing success or usage over a newer failure. +**Live stream recovery.** When `stream_recovery_mode` is not `off`, +`createLiveRecoveryResponse` (`src/core/request/recovery-integration.ts`) hands the +outbound `Response` to a `StreamRecoveryCoordinator` +(`src/core/request/stream-recovery.ts`). The coordinator owns **one** outbound SSE +byte stream across multiple SDK attempts: attempts stay pre-SSE-encoding (each keeps +its own transformer, `EmittedOutputAccumulator`, and `StreamObserver`), the coordinator +is the sole publication point, and it withholds a terminal chunk plus everything after +it until that attempt drains cleanly — so a failed attempt can never publish a +synthetic success. It fires `onComplete` at most once and `onTerminal` exactly once on +every exit path (drain, error, abort, cancel). Attempts are opened by +`RecoveryAttemptFactory` (`recovery-attempt.ts`); each recovery attempt is a real SDK +send and consumes quota. + +`decideRecoveryTier` picks the tier from the mode plus what has already been +delivered: + +- **Tier A `reasoning_restart`** — eligible only when zero visible chars, zero + tool calls, and no observed tool intent have been delivered. Only reasoning was + lost, so the next attempt simply continues the same SSE. +- **Tier B `exact_replay`** — only under `exact_replay` mode, and only once visible + text or tool calls were delivered. `ExactReplayMatcher` + (`src/core/request/replay-matcher.ts`) byte-exactly matches the new attempt against + the delivered three-channel prefix (reasoning / visible text / tool calls). + **Zero chunks are delivered until the whole delivered prefix is matched**; any + divergence — or a terminal chunk arriving before catch-up (`early_end`) — aborts + that attempt. Each attempt reports `Kiro exact replay attempt finished` telemetry. +- **`none`** — under `off`, or when neither tier is eligible; the failure is mapped + to `UpstreamUnexpectedError` and terminates the stream. + +**Semantic truncation** is decided ONLY by an unclosed tool intent +(`StreamObserver.hasOpenToolIntent`), never by missing completion metadata — see +`isSemanticTruncation` in `response-handler.ts`. Dialect tool-intent closure is +tri-state (`DialectToolResolution` = `none` | `complete` | `incomplete`, +`src/infrastructure/transformers/tool-call-parser.ts`) and shares the tool-call +parser's code-region rules. Under a recovery mode, an `incomplete` resolution makes +`transformSdkStream` suppress both the dialect `remainderText` and the whole turn's +tool calls (raw SDK tool calls included), so half an invocation or a partial tool set +never leaks to the consumer. + +**Reasoning-signature publication is tier-dependent** (`commitReasoningCorrelation`, +`request-handler.ts`). A Tier A recovery reports `recovered: true` and MUST NOT +publish its reasoning envelope: the delivered reasoning is old-partial + new-full, +which does not match the final attempt's envelope, so publishing would be a false hit +and the next turn would fail `THINKING_SIGNATURE_INVALID`. Loop lifecycle cleanup +still runs. A Tier B caught-up replay reports `recovered: false` and MAY publish, +because after a byte-exact prefix match the whole delivered response equals that +replay attempt's own complete output. The staleness gate here is **request-scoped** +(`owningAttemptId` compared against the request's latest attempt id), never the +per-account attempt epoch — that epoch is bumped by unrelated same-account requests, +so keying on it would drop healthy concurrent streams' envelopes. + +**Observability.** `StreamObserver` +(`src/plugin/streaming/stream-observer.ts`) is write-only from the transformer's +perspective — the transformer never reads it back, so attaching one cannot change an +emitted chunk. It is threaded in via `lifecycle.streamObserver` and exposes +`sawToolIntent`, `hasOpenToolIntent`, `reasoningPhase` (`none` | `active` | `ended`), +and `dialectActive`. Stream failure logs carry `emittedReasoningChars`, +`emittedVisibleChars`, `emittedToolCount`, `sawToolIntent`, plus the transport-side +`sdkHttpKeepAlive`, `processId`, `bunVersion`, `streamElapsedMs`, and +`upstreamEventCount`. Two log-event constants live in +`src/core/request/stream-log-events.ts` and are re-exported from `request-handler.ts`: +`STREAM_REQUEST_STARTED_LOG` (`Kiro stream request started`, written unconditionally +once per inbound streaming request — the denominator for failure-rate measurement) and +`STREAM_MISSING_COMPLETION_LOG` (`Kiro stream ended without completion metadata`, a +benign WARN that fires on essentially every stream from this endpoint). + +**Transport.** `sdk_http_keep_alive` (default `false`) disables socket reuse after a +request completes, via `httpsAgent: { keepAlive, maxSockets: SDK_MAX_SOCKETS }` in +`createSdkClient` (`src/plugin/sdk-client.ts`); `maxSockets` stays 50. Fresh sockets +mitigate Bun stale-connection `ECONNRESET` mid-stream at the cost of one extra +TCP/TLS handshake per request. The flag is part of the SDK client cache key. + +**History pollution.** Collapsed assistant turns in `collapseAgenticLoops` +(`src/infrastructure/transformers/history-builder.ts`) carry `content: ''`, matching +the official Kiro IDE shape. `stripPollutionMarkers` +(`src/infrastructure/transformers/message-transformer.ts`) scrubs this plugin's own +marker literals (`[system: tool calling continues]`, +`[system: conversation continues]`) out of inbound assistant `content` and `thinking` +as history is rebuilt, because the model copies them into its visible output and the +client replays them forever. The `` text fallback is bounded to a single +turn — the most recent assistant message (`findThinkingTextReplayIndex`) — since +flattening reasoning into assistant text on every replayed turn teaches the model to +narrate instead of calling tools; one turn rather than zero because Tier A recovery +deliberately misses the signature cache. Adjacent same-role turns are merged +(`mergeIntoPreviousUserTurn`) so no synthetic assistant separator turns are produced; +`MAX_KIRO_IMAGES` is exported from `src/plugin/image-handler.ts` and shared by that +merge path. + **Storage concurrency.** Several OpenCode processes share one `kiro.db`, so every write path has to assume contention. Runtime writes do NOT take a global file lock: each of `KiroDatabase`'s write methods runs its read-modify-write inside a @@ -174,21 +268,21 @@ and is the only class with direct access to the OpenCode `client` (used for ## 3. Directory guide -| Path | Contents | -|---|---| -| `src/core/auth/` | `AuthHandler`, `IdcAuthMethod`, `TokenRefresher` — OAuth methods and access-token refresh logic. | -| `src/core/request/` | `RequestHandler` (main loop + stream-iteration retry), `ErrorHandler` (HTTP status handling incl. 402/403/429), `ResponseHandler` (SDK/stream -> OpenAI response), `RetryStrategy`, `stream-error.ts` (`SdkEventStreamIterationError` / `UpstreamUnexpectedError`). | -| `src/core/account/` | `AccountSelector` (sticky/round-robin/lowest-usage), `UsageTracker`. | -| `src/plugin/config/` | Zod schema + `loadConfig`/`loader.ts` (user + project `kiro.json` merge). | -| `src/plugin/storage/` | `sqlite.ts` (`KiroDatabase`, `DB_PATH` = `kiro.db`, `withImmediateTransaction`), `migrations.ts` (schema migrations + `plugin_meta` markers), `locked-operations.ts` (`mergeAccounts`/`deduplicateAccounts` plus the three remaining `proper-lockfile` scopes: schema init, per-account refresh, keep-alive leader election). | -| `src/plugin/streaming/` | Stream transformers: raw Kiro event stream and SDK event stream -> OpenAI SSE chunks. | -| `src/plugin/sync/` | `syncFromKiroCli` — imports credentials/profile from the external `kiro-cli`'s own `data.sqlite3`. | -| `src/kiro/` | `auth.ts` (token decode/expiry helpers), `oauth-idc.ts` (IDC OAuth device flow, `authorizeKiroIDC`). | -| `src/infrastructure/database/` | `AccountRepository`, `AccountCache` — persistence layer in front of `KiroDatabase`. | -| `src/infrastructure/transformers/` | Message/history/tool-call transformers between OpenAI-shaped input and CodeWhisperer's `conversationState` shape. | -| `src/__tests__/` | `bun:test` suite (72 test files) — includes `provider-id-collision.test.ts`, `sqlite-concurrency.test.ts`, and `sqlite-multiprocess-stress.test.ts`. | -| `src/plugin.ts`, `src/index.ts`, `index.ts` (root) | Plugin composition root and public exports. | -| `src/constants.ts` | `KIRO_CONSTANTS`, `MODEL_MAPPING`, `KIRO_AUTH_SERVICE`, region helpers. | +| Path | Contents | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/core/auth/` | `AuthHandler`, `IdcAuthMethod`, `TokenRefresher` — OAuth methods and access-token refresh logic. | +| `src/core/request/` | `RequestHandler` (main loop + stream-iteration retry), `ErrorHandler` (HTTP status handling incl. 402/403/429), `ResponseHandler` (SDK/stream -> OpenAI response), `RetryStrategy`, `stream-error.ts` (`SdkEventStreamIterationError` / `UpstreamUnexpectedError`), `stream-recovery.ts` (`StreamRecoveryCoordinator`, `decideRecoveryTier`), `recovery-attempt.ts` (`RecoveryAttemptFactory`), `recovery-integration.ts` (`createLiveRecoveryResponse`), `replay-matcher.ts` (`ExactReplayMatcher`), `stream-log-events.ts` (the two stable log-event constants). | +| `src/core/account/` | `AccountSelector` (sticky/round-robin/lowest-usage), `UsageTracker`. | +| `src/plugin/config/` | Zod schema + `loadConfig`/`loader.ts` (user + project `kiro.json` merge). | +| `src/plugin/storage/` | `sqlite.ts` (`KiroDatabase`, `DB_PATH` = `kiro.db`, `withImmediateTransaction`), `migrations.ts` (schema migrations + `plugin_meta` markers), `locked-operations.ts` (`mergeAccounts`/`deduplicateAccounts` plus the three remaining `proper-lockfile` scopes: schema init, per-account refresh, keep-alive leader election). | +| `src/plugin/streaming/` | Stream transformers: raw Kiro event stream and SDK event stream -> OpenAI SSE chunks; `stream-observer.ts` (`StreamObserver`, write-only ingestion-time signals). | +| `src/plugin/sync/` | `syncFromKiroCli` — imports credentials/profile from the external `kiro-cli`'s own `data.sqlite3`. | +| `src/kiro/` | `auth.ts` (token decode/expiry helpers), `oauth-idc.ts` (IDC OAuth device flow, `authorizeKiroIDC`). | +| `src/infrastructure/database/` | `AccountRepository`, `AccountCache` — persistence layer in front of `KiroDatabase`. | +| `src/infrastructure/transformers/` | Message/history/tool-call transformers between OpenAI-shaped input and CodeWhisperer's `conversationState` shape. | +| `src/__tests__/` | `bun:test` suite (90 test files, 1185 tests) — includes `provider-id-collision.test.ts`, `sqlite-concurrency.test.ts`, and `sqlite-multiprocess-stress.test.ts`. | +| `src/plugin.ts`, `src/index.ts`, `index.ts` (root) | Plugin composition root and public exports. | +| `src/constants.ts` | `KIRO_CONSTANTS`, `MODEL_MAPPING`, `KIRO_AUTH_SERVICE`, region helpers. | ## 4. Critical invariants — DO NOT BREAK @@ -233,6 +327,30 @@ and is the only class with direct access to the OpenCode `client` (used for - `auth.desktop.kiro.dev` refresh endpoint (`constants.ts:39`, `KIRO_AUTH_SERVICE.ENDPOINT` at `constants.ts:144`, `plugin/token.ts:11`). - `q.{region}.amazonaws.com` CodeWhisperer base URL (`constants.ts:41-42`). - `ORIGIN_AI_EDITOR: 'AI_EDITOR'` message origin (`constants.ts:49`, used in `history-builder.ts` and `plugin/request.ts`). +- **Semantic truncation must never key off missing completion metadata.** + `STREAM_MISSING_COMPLETION_LOG` fires on essentially every stream this endpoint + serves, so treating "no completion metadata" as truncation declares every healthy + turn truncated and makes a recovery mode replay all of them. The only truncation + signal is an unclosed tool intent (`StreamObserver.hasOpenToolIntent`). +- **Keep the reasoning-signature publication split intact.** A Tier A recovery + (`recovered: true`) must NOT publish its reasoning envelope — the delivered + reasoning is old-partial + new-full, so publishing produces a false cache hit and + the next turn fails `THINKING_SIGNATURE_INVALID`. A Tier B caught-up replay MAY + publish. And the staleness gate must stay **request-scoped** (`owningAttemptId` vs + the request's latest attempt id); switching it to the per-account attempt epoch + drops envelopes belonging to healthy concurrent streams on the same account. +- **Never put protocol narration or synthetic placeholder text into an assistant + turn's `content`.** The model imitates whatever it sees in assistant history, so a + separator like `[system: tool calling continues]` comes back as visible output, the + client persists it, and it replays forever. This caused multiple regressions. + Collapsed turns carry `content: ''`; inbound assistant `content`/`thinking` is + scrubbed by `stripPollutionMarkers`; adjacent same-role turns are merged instead of + separated by a synthetic assistant turn. +- **`stream_recovery_mode: 'off'` must stay byte-identical to pre-recovery + behavior.** It is the default, so every recovery feature has to be gated on the + mode: no extra SDK sends, no suppression of dialect remainder text or tool calls, + no truncation verdict. Observation (`StreamObserver`) is allowed because it is + write-only and cannot change an emitted chunk. - **Do not hardcode a wire id for an unreleased model** — every entry in `MODEL_MAPPING` (`src/constants.ts:52`) must be backed by an observed 200 response from the real API before being added. Sonnet 5 is now probe-confirmed @@ -281,22 +399,39 @@ four does not surface them. `sqlite-multiprocess-stress.test.ts` uses five. loose `any` at plugin boundaries — don't propagate that pattern into new code). - Keep AWS-facing literals (headers, URLs, model ids) centralized in `src/constants.ts` rather than inlined at new call sites. +- Determine the AWS wire schema from real IDE traffic captures; treat the SDK's + TypeScript types as corroboration only. Concretely: + `reasoningContent{reasoningText:{text,signature}}` _is_ a field the official Kiro + IDE sends in request-side `conversationState.history`, confirmed by a capture, even + though the SDK's `AssistantResponseMessage` type does not list it. The types are + incomplete or lag actual IDE behavior. ## 7. Where things live (quick index) -| What | Where | -|---|---| -| Provider id | `src/plugin.ts:14` (`KIRO_PROVIDER_ID`), used at `src/plugin.ts:431` | -| Model id map | `src/constants.ts` `MODEL_MAPPING` (line 52) | -| Model resolution | `src/plugin/models.ts` `resolveKiroModel` | -| Request loop | `src/core/request/request-handler.ts` `RequestHandler.handle` | -| Error / 402/403/429 handling | `src/core/request/error-handler.ts` `ErrorHandler.handle` | -| Token refresh | `src/core/auth/token-refresher.ts` `TokenRefresher` + `src/plugin/token.ts` `refreshAccessToken` | -| kiro-cli sync | `src/plugin/sync/kiro-cli.ts` `syncFromKiroCli` | -| Config load | `src/plugin/config/loader.ts` | -| SQLite storage | `src/plugin/storage/sqlite.ts` `KiroDatabase` / `DB_PATH` (line 49) | -| DB write transactions | `src/plugin/storage/sqlite.ts:64` `withImmediateTransaction` | -| Remaining file locks | `src/plugin/storage/locked-operations.ts:79,111,134` | -| Schema migrations / markers | `src/plugin/storage/migrations.ts:61` `runMigrations` | -| SDK client construction | `src/plugin/sdk-client.ts` `createSdkClient` | -| Response -> OpenAI shape | `src/core/request/response-handler.ts` `ResponseHandler` | +| What | Where | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provider id | `src/plugin.ts:14` (`KIRO_PROVIDER_ID`), used at `src/plugin.ts:431` | +| Model id map | `src/constants.ts` `MODEL_MAPPING` (line 52) | +| Model resolution | `src/plugin/models.ts` `resolveKiroModel` | +| Request loop | `src/core/request/request-handler.ts` `RequestHandler.handle` | +| Error / 402/403/429 handling | `src/core/request/error-handler.ts` `ErrorHandler.handle` | +| Token refresh | `src/core/auth/token-refresher.ts` `TokenRefresher` + `src/plugin/token.ts` `refreshAccessToken` | +| kiro-cli sync | `src/plugin/sync/kiro-cli.ts` `syncFromKiroCli` | +| Config load | `src/plugin/config/loader.ts` | +| SQLite storage | `src/plugin/storage/sqlite.ts` `KiroDatabase` / `DB_PATH` (line 49) | +| DB write transactions | `src/plugin/storage/sqlite.ts:64` `withImmediateTransaction` | +| Remaining file locks | `src/plugin/storage/locked-operations.ts:79,111,134` | +| Schema migrations / markers | `src/plugin/storage/migrations.ts:61` `runMigrations` | +| SDK client construction | `src/plugin/sdk-client.ts` `createSdkClient` | +| Response -> OpenAI shape | `src/core/request/response-handler.ts` `ResponseHandler` | +| Live stream recovery entry | `src/core/request/recovery-integration.ts` `createLiveRecoveryResponse` | +| Recovery coordinator / tier decision | `src/core/request/stream-recovery.ts` `StreamRecoveryCoordinator`, `decideRecoveryTier` | +| Recovery attempt opening | `src/core/request/recovery-attempt.ts` `RecoveryAttemptFactory` | +| Tier B prefix matching | `src/core/request/replay-matcher.ts` `ExactReplayMatcher` | +| Stream observation signals | `src/plugin/streaming/stream-observer.ts` `StreamObserver` | +| Stable stream log events | `src/core/request/stream-log-events.ts` (re-exported from `request-handler.ts`) | +| Semantic truncation verdict | `src/core/request/response-handler.ts` `isSemanticTruncation` | +| Reasoning-signature publication gate | `src/core/request/request-handler.ts` `commitReasoningCorrelation` | +| Stream/transport config keys | `src/plugin/config/schema.ts` — `stream_recovery_mode`:174, `stream_max_attempts`:167, `stream_buffer_until_complete`:161, `sdk_http_keep_alive`:141 | +| Pollution-marker scrubbing | `src/infrastructure/transformers/message-transformer.ts` `stripPollutionMarkers`, `findThinkingTextReplayIndex` | +| Loop collapse / turn merge | `src/infrastructure/transformers/history-builder.ts` `collapseAgenticLoops`, `mergeIntoPreviousUserTurn` |