Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,11 @@ delivered:
**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.
- **Clean-EOF action-commitment replay** — separate from semantic truncation. Under
`exact_replay` only, a clean EOF may spend one remaining attempt when the exact
request exposes tools, the response emitted visible text but zero tool calls and
no tool intent, and `action-commitment.ts` recognizes either an immediate
first-person execution promise or an ordered unfinished self-owned action
sequence. The same `ExactReplayMatcher` withholds duplicate bytes. This dedicated
retry runs at most once and does not fail, rotate, rate-limit, or back off the
healthy account.
- **Fully empty clean-EOF retry** — separate from semantic truncation. Under either
non-`off` recovery mode, a clean EOF may spend one remaining attempt only when it
emitted zero visible chars, zero tool calls, and no tool intent. This dedicated
retry runs at most once on the same account and does not classify a failure,
rotate, rate-limit, or back off the healthy account.
- **`none`** — under `off`, or when neither tier is eligible; the failure is mapped
to `UpstreamUnexpectedError` and terminates the stream.

Expand All @@ -168,8 +165,7 @@ tri-state (`DialectToolResolution` = `none` | `complete` | `incomplete`,
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. The action-commitment replay above is a separate,
pattern-gated reliability guard; it must not be labeled or implemented as truncation.
never leaks to the consumer. Assistant prose is never a truncation or retry signal.

**Reasoning-signature publication is tier-dependent** (`commitReasoningCorrelation`,
`request-handler.ts`). A Tier A recovery reports `recovered: true` and MUST NOT
Expand All @@ -191,13 +187,13 @@ emitted chunk. It is threaded in via `lifecycle.streamObserver` and exposes
and `dialectActive`. Stream failure logs carry `emittedReasoningChars`,
`emittedVisibleChars`, `emittedToolCount`, `sawToolIntent`, plus the transport-side
`sdkHttpKeepAlive`, `processId`, `bunVersion`, `streamElapsedMs`, and
`upstreamEventCount`. Three 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
`upstreamEventCount`. Stable log-event constants live in
`src/core/request/stream-log-events.ts`: `STREAM_REQUEST_STARTED_LOG` (`Kiro stream
request started`, written unconditionally once per inbound streaming request — the
denominator for failure-rate measurement), `STREAM_ATTEMPT_STARTED_LOG`,
`STREAM_MISSING_COMPLETION_LOG` (`Kiro stream ended without completion metadata`, a
benign WARN that fires on essentially every stream from this endpoint), plus
`STREAM_ACTION_COMMITMENT_RETRY_LOG` for the narrow one-shot replay.
benign WARN that fires on essentially every stream from this endpoint),
`STREAM_EMPTY_CLEAN_EOF_RETRY_LOG`, and `STREAM_TERMINAL_LOG`.

**Transport.** `sdk_http_keep_alive` (default `false`) disables socket reuse after a
request completes, via `httpsAgent: { keepAlive, maxSockets: SDK_MAX_SOCKETS }` in
Expand Down Expand Up @@ -341,11 +337,10 @@ and is the only class with direct access to the OpenCode `client` (used for
`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`). The narrow
clean-EOF action-commitment replay is not a truncation verdict: preserve every
additional gate in `StreamRecoveryCoordinator`, including `exact_replay`, available
tools, zero tool calls/intent, explicit text pattern, one-use flag, and remaining
attempt budget.
signal is an unclosed tool intent (`StreamObserver.hasOpenToolIntent`). The only
clean-EOF retry is the one-shot fully empty response path. Never inspect assistant
prose to decide whether to retry: a non-empty clean EOF completes regardless of its
wording.
- **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
Expand Down Expand Up @@ -442,7 +437,6 @@ four does not surface them. `sqlite-multiprocess-stress.test.ts` uses five.
| 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` |
| Clean-EOF action commitment detector | `src/core/request/action-commitment.ts` `detectForwardActionCommitment` |
| 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` |
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,13 @@ are already atomic by default; for ordinary requests, enable
`stream_buffer_until_complete` when task continuity is more important than
seeing tokens arrive live.

With `stream_recovery_mode: "exact_replay"`, the plugin may make one additional
SDK send after that otherwise-benign clean EOF only when a tool-enabled response
ends with an explicit immediate or unfinished self-owned action commitment but
contains no tool call or tool intent. The replay must byte-match the already
delivered response before any new suffix is released. This is not a generic
missing-metadata retry, and the additional SDK send consumes quota.
Assistant prose is never interpreted as a recovery control signal. A non-empty
clean EOF completes normally regardless of whether the response describes a next
step. With `stream_recovery_mode` set to `reasoning_restart` or `exact_replay`, the
plugin may spend one additional same-account SDK send only for a fully empty clean
EOF with no tool output or tool intent. Iterator failures and unclosed tool intent
continue through the configured recovery tier; every additional SDK send consumes
quota.

## Migration

Expand Down
99 changes: 0 additions & 99 deletions src/__tests__/action-commitment.test.ts

This file was deleted.

175 changes: 34 additions & 141 deletions src/__tests__/recovery-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import {
type LiveRecoveryOptions
} from '../core/request/recovery-integration.js'
import type { SdkStreamingAttempt } from '../core/request/response-handler.js'
import {
STREAM_ACTION_COMMITMENT_RETRY_LOG,
STREAM_EMPTY_CLEAN_EOF_RETRY_LOG
} from '../core/request/stream-log-events.js'
import { STREAM_EMPTY_CLEAN_EOF_RETRY_LOG } from '../core/request/stream-log-events.js'
import { AccountManager } from '../plugin/accounts.js'
import * as logger from '../plugin/logger.js'
import type { ManagedAccount } from '../plugin/types.js'
Expand Down Expand Up @@ -518,157 +515,53 @@ describe('createLiveRecoveryResponse — account rotation', () => {
}
})

test('action-commitment replay does not classify or rotate the healthy account', async () => {
const commitment = '我现在派两个并行任务。'
const account = makeAccount('commitment')
let selectCalls = 0
let markRateLimitedCalls = 0
let retryDelayCalls = 0
let waitCalls = 0
const warn = spyOn(logger, 'warn').mockImplementation(() => {})
const attemptFactory: Pick<RecoveryAttemptFactory, 'open'> = {
open: async (attemptIndex, selectedAccount) => ({
account: selectedAccount,
logDetails: (details = {}) => ({
conversationId: 'conversation-action-commitment',
model: 'claude-opus-5-xhigh',
terminalSource: 'clean_eof_without_completion_metadata',
...details
}),
handle: {
...(attemptIndex === 1
? makeAttempt({
output: [
chunk('commitment', { content: commitment }),
chunk('discarded-finish', {}, 'stop')
],
observation: {
emitted: { visibleChars: commitment.length, toolCount: 0 },
sawToolIntent: false,
terminalSource: 'clean_eof_without_completion_metadata',
availableToolCount: 94,
forwardActionCommitment: 'zh_immediate_first_person'
}
})
: makeAttempt({
output: [
chunk('shadow', { content: commitment }),
chunk('task', {
tool_calls: [
{
index: 0,
id: 'tool-1',
function: { name: 'task', arguments: '{"description":"fix"}' }
}
]
}),
chunk('finish', {}, 'tool_calls')
]
})),
complete: async () => {}
}
})
}
const harness = recoveryOptions(attemptFactory)

try {
const response = await createLiveRecoveryResponse({
...harness.options,
mode: 'exact_replay',
initialAccount: account,
retryDelay: () => {
retryDelayCalls++
return 0
},
wait: async () => {
waitCalls++
},
selectAlternativeAccount: async () => {
selectCalls++
return null
},
markRateLimited: () => {
markRateLimitedCalls++
}
})
await response.text()

expect(harness.options.failedAccountIds).toEqual(new Set())
expect(selectCalls).toBe(0)
expect(markRateLimitedCalls).toBe(0)
expect(retryDelayCalls).toBe(0)
expect(waitCalls).toBe(0)
expect(
warn.mock.calls.filter((call) => call[0] === 'Kiro SDK event stream iteration failed')
).toEqual([])
expect(
warn.mock.calls.find((call) => call[0] === STREAM_ACTION_COMMITMENT_RETRY_LOG)?.[1]
).toMatchObject({
outcome: 'retrying',
recoveryTrigger: 'clean_eof_action_commitment',
actionCommitmentPattern: 'zh_immediate_first_person',
actionCommitmentVisibleChars: commitment.length,
availableToolCount: 94,
nextAttempt: 2
})
expect(harness.terminalRecords()).toEqual([
expect.objectContaining({
conversationId: 'conversation-action-commitment',
attemptsUsed: 2,
accountsTried: 1,
initialFailure: null,
finalFailure: null,
recovered: true,
quotaRelevant: false,
actionCommitmentRetryDecision: 'retried',
terminalSource: 'clean_eof_without_completion_metadata'
})
])
} finally {
warn.mockRestore()
}
})

test('terminal record explains a clean EOF action-commitment pattern miss', async () => {
const answer = '当前分析已经完成。'
test('non-empty clean EOF prose does not open a recovery attempt', async () => {
const prose = '我先派发第二批三站'
const openedAttempts: number[] = []
const attemptFactory: Pick<RecoveryAttemptFactory, 'open'> = {
open: async (_attemptIndex, selectedAccount) => ({
account: selectedAccount,
logDetails: (details = {}) => ({
conversationId: 'conversation-action-pattern-miss',
model: 'claude-opus-5-xhigh',
terminalSource: 'clean_eof_without_completion_metadata',
...details
}),
handle: {
...makeAttempt({
output: [chunk('answer', { content: answer }), chunk('accepted-finish', {}, 'stop')],
observation: {
emitted: { visibleChars: answer.length, toolCount: 0 },
sawToolIntent: false,
terminalSource: 'clean_eof_without_completion_metadata',
availableToolCount: 94,
forwardActionCommitment: null
}
open: async (attemptIndex, selectedAccount) => {
openedAttempts.push(attemptIndex)
return {
account: selectedAccount,
logDetails: (details = {}) => ({
conversationId: 'conversation-action-prose',
model: 'claude-opus-5-xhigh',
terminalSource: 'clean_eof_without_completion_metadata',
...details
}),
complete: async () => {}
handle: {
...makeAttempt({
output: [chunk('action-prose', { content: prose }), chunk('finish', {}, 'stop')],
observation: {
emitted: { visibleChars: prose.length, toolCount: 0 },
sawToolIntent: false,
terminalSource: 'clean_eof_without_completion_metadata'
}
}),
complete: async () => {}
}
}
})
}
}
const harness = recoveryOptions(attemptFactory)

const response = await createLiveRecoveryResponse({
...harness.options,
mode: 'exact_replay'
mode: 'exact_replay',
maxAttempts: 2
})
await response.text()

expect(await response.text()).toContain(prose)
expect(openedAttempts).toEqual([1])
expect(harness.terminalRecords()).toEqual([
expect.objectContaining({
conversationId: 'conversation-action-pattern-miss',
conversationId: 'conversation-action-prose',
attemptsUsed: 1,
accountsTried: 1,
initialFailure: null,
finalFailure: null,
recovered: false,
actionCommitmentRetryDecision: 'pattern_not_detected',
quotaRelevant: false,
terminalSource: 'clean_eof_without_completion_metadata'
})
])
Expand Down
Loading
Loading