From 565a359e00d5e3785b51f4285d80a686f4b710c3 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 11:48:51 +0800 Subject: [PATCH 01/12] fix(runtime): preserve provider diagnostics on failed turns Generated-by: Codex --- packages/core/src/session.ts | 9 ++- .../canonical-session-projection.test.ts | 5 +- .../src/__tests__/session-turns.test.ts | 42 ++++++++++++ .../src/adapter/session-projector.ts | 9 ++- packages/runtime-host/src/protocol/index.ts | 3 + .../src/protocol/session-turns.ts | 17 +++++ .../src/server/canonical-turn-snapshot.ts | 13 +++- .../src/server/shared-session-transcript.ts | 1 + .../__tests__/model-adapter-onerror.test.ts | 6 ++ .../src/__tests__/model-adapter.test.ts | 22 ++++++ .../openai-responses-model-adapter.test.ts | 2 + .../runtime-event-read-model.test.ts | 30 ++++++++ packages/runtime/src/agent-run.ts | 15 +++- packages/runtime/src/model-adapter.ts | 14 +++- packages/runtime/src/model-protocol.ts | 2 + .../src/provider-error-classification.ts | 2 +- .../runtime/src/runtime-event-backfill.ts | 10 +++ .../runtime/src/runtime-event-read-model.ts | 17 +++++ packages/runtime/src/runtime-kernel.ts | 9 ++- packages/runtime/src/session-manager.ts | 8 ++- .../runtime/src/session-projection-helpers.ts | 27 +++++++- .../chat-turn-steering-order.test.ts | 28 ++++++++ packages/ui/src/__tests__/materialize.test.ts | 18 +++++ packages/ui/src/chat-turn.tsx | 68 +++++++++++-------- packages/ui/src/chat-view.tsx | 3 + packages/ui/src/conversation-copy.ts | 3 + packages/ui/src/materialize.ts | 2 + packages/ui/src/styles.css | 13 ++++ 28 files changed, 356 insertions(+), 42 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index dc03aea7be..5f05dd48e3 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -935,6 +935,8 @@ export interface TurnStateMessage { /** Diagnostic source for user/renderer-triggered aborts, e.g. renderer.stop_button. */ abortSource?: string; errorClass?: string; + /** Bounded provider response summary for a failed turn. */ + failureMessage?: string; partialOutputRetained: boolean; } @@ -1144,6 +1146,8 @@ export interface TurnRecord { abortedAt?: number; abortSource?: string; errorClass?: string; + /** Bounded provider response summary for a failed turn. */ + failureMessage?: string; partialOutputRetained: boolean; } @@ -1256,6 +1260,7 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape()( 'abortedAt', 'abortSource', 'errorClass', + 'failureMessage', ], ); const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE = @@ -1547,7 +1552,8 @@ function decodeMessage( isOptionalString(message.parentSessionId) && (message.abortedAt === undefined || isFiniteNumber(message.abortedAt)) && isOptionalString(message.abortSource) && - isOptionalString(message.errorClass) + isOptionalString(message.errorClass) && + isOptionalString(message.failureMessage) ) return message as unknown as TurnStateMessage; break; @@ -1841,6 +1847,7 @@ export function deriveTurnRecords(messages: readonly StoredMessage[]): TurnRecor ...(latestState.abortedAt !== undefined ? { abortedAt: latestState.abortedAt } : {}), ...(latestState.abortSource ? { abortSource: latestState.abortSource } : {}), ...(latestState.errorClass ? { errorClass: latestState.errorClass } : {}), + ...(latestState.failureMessage ? { failureMessage: latestState.failureMessage } : {}), partialOutputRetained: latestState.partialOutputRetained || partialOutputRetained, }; } diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index dd8c16dc9b..1367aad5e8 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -385,6 +385,9 @@ test('projects a failed Turn message from the canonical terminal event', async ( recoverable: false, code: 'provider_error', message: 'canonical provider failure api_key=sk-test-secret-value', + details: { + providerSummary: 'provider rejected model (code=provider_error, requestId=req-123)', + }, }, context, memory, @@ -415,7 +418,7 @@ test('projects a failed Turn message from the canonical terminal event', async ( if (canonical?.rootTurn?.status === 'failed') { assert.equal( canonical.rootTurn.failureMessage, - 'canonical provider failure api_key=[redacted]', + 'provider rejected model (code=provider_error, requestId=req-123)', ); } }); diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts index 945e36e780..a09b30f2e7 100644 --- a/packages/runtime-host/src/__tests__/session-turns.test.ts +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -26,6 +26,7 @@ import { projectSessionTurnContribution, projectSessionTurnContributionForWire, SESSION_TURN_DIAGNOSTIC_MAX_BYTES, + SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES, SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, } from '../protocol/session-turns.js'; @@ -110,6 +111,47 @@ test('bounds turn diagnostics before publishing a contribution', () => { ); }); +test('persists and bounds the provider failure summary in the Turn projection', () => { + const contribution = projectSessionTurnContributionForWire({ + turnId: 'turn-1', + firstSequence: 0, + latestState: { + sequence: 0, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 1, + status: 'failed', + partialOutputRetained: false, + errorClass: 'rate_limit', + failureMessage: `provider says ${'x'.repeat(10_000)}`, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }); + + assert.ok( + Buffer.byteLength(contribution.latestState!.message.failureMessage!, 'utf8') <= + SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES, + ); + const decoded = decodeSessionTurnsQueryResult({ + sessionId: 'session-1', + throughSequence: 0, + contributions: [contribution], + nextPosition: null, + }); + assert.equal( + decoded.contributions[0]!.latestState!.message.failureMessage, + contribution.latestState!.message.failureMessage, + ); +}); + test('rejects invalid turn-state references before publishing a contribution', () => { assert.throws(() => projectSessionTurnContributionForWire({ diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 3687f6d389..12cfa15c00 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -290,7 +290,10 @@ export class RuntimeHostSessionProjector { ts: terminal.ts, recoverable: false, reason, - message: `Turn failed: ${reason}`, + message: terminal.failureMessage ?? `Turn failed: ${reason}`, + ...(terminal.failureMessage + ? { details: { providerSummary: terminal.failureMessage } } + : {}), }); } else { events.push({ @@ -329,7 +332,8 @@ export class RuntimeHostSessionProjector { ts, recoverable: false, reason, - message: `Turn failed: ${reason}`, + message: turn.failureMessage ?? `Turn failed: ${reason}`, + ...(turn.failureMessage ? { details: { providerSummary: turn.failureMessage } } : {}), }, ]; } @@ -486,6 +490,7 @@ export class RuntimeHostSessionProjector { recoverable: false, reason: root.failureClass, message: root.failureMessage ?? `Turn failed: ${root.failureClass}`, + ...(root.failureMessage ? { details: { providerSummary: root.failureMessage } } : {}), }); } else { events.push({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..12740d8864 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -138,6 +138,9 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; // canonical OAuth material with an empty enable-all-discovered selection. // Older peers reject these wire values, so incompatible pairs must fail the // handshake. Re-derived from current `main`; epoch 102 is claimed by open PRs. +// 110: Session Turn projections carry bounded provider failure summaries for +// live and reloaded failed-turn diagnostics. Older peers cannot preserve or +// render this additional failure context safely. // 101: Session Turn requests can carry regeneration intents and Guests can // atomically withdraw pending requests. Older peers do not share this command // vocabulary or the expanded Guest operation grant. diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index c615b7152a..b3c0df009d 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -32,6 +32,7 @@ import { defineOperation } from './operation-spec.js'; export const SESSION_TURN_QUERY_MAX_CONTRIBUTIONS = 128; export const SESSION_TURN_QUERY_RESULT_MAX_BYTES = 192 * 1024; export const SESSION_TURN_DIAGNOSTIC_MAX_BYTES = 128; +export const SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES = 256; export const SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES = 256; export const SESSION_TURN_LANDMARK_MAX_ITEMS = 64; export const SESSION_TURN_LANDMARK_LABEL_MAX_BYTES = 96; @@ -179,6 +180,14 @@ function projectTurnStateMessageForWire(message: TurnStateMessage): TurnStateMes ...(message.errorClass ? { errorClass: truncateUtf8(message.errorClass, SESSION_TURN_DIAGNOSTIC_MAX_BYTES) } : {}), + ...(message.failureMessage + ? { + failureMessage: truncateUtf8( + message.failureMessage, + SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES, + ), + } + : {}), partialOutputRetained: message.partialOutputRetained, }; } @@ -205,6 +214,7 @@ export function projectSessionTurnContribution(contribution: SessionTurnContribu ...(state.abortedAt !== undefined ? { abortedAt: state.abortedAt } : {}), ...(state.abortSource ? { abortSource: state.abortSource } : {}), ...(state.errorClass ? { errorClass: state.errorClass } : {}), + ...(state.failureMessage ? { failureMessage: state.failureMessage } : {}), partialOutputRetained: state.partialOutputRetained || partialOutputRetained, }; } @@ -424,6 +434,13 @@ function decodeSessionTurnContribution(value: unknown): SessionTurnContribution SESSION_TURN_DIAGNOSTIC_MAX_BYTES, ); } + if (message.failureMessage !== undefined) { + requireUtf8String( + message.failureMessage, + 'Session turn failure message', + SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES, + ); + } latestState = { sequence: requireCount(state.sequence, 'Session turn state sequence'), message, diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 1ba5b1968c..6d51515a0b 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -71,7 +71,10 @@ export async function readCanonicalTurnSnapshot( const failureMessage = fact.terminalEvent.content?.kind === 'error' ? truncateUtf8( - redactSecrets(fact.terminalEvent.content.message), + redactSecrets( + providerFailureSummaryFromRuntimeEvent(fact.terminalEvent) ?? + fact.terminalEvent.content.message, + ), TURN_FAILURE_MESSAGE_MAX_BYTES, '…', ) @@ -121,6 +124,14 @@ async function hasPendingInteraction( ); } +function providerFailureSummaryFromRuntimeEvent( + event: import('@maka/core/runtime-event').RuntimeEvent, +): string | undefined { + const details = event.content?.kind === 'error' ? event.content.details : undefined; + if (!details || Array.isArray(details)) return undefined; + const summary = details.providerSummary; + return typeof summary === 'string' && summary.length > 0 ? summary : undefined; +} function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { if (!value || typeof value !== 'object') return undefined; const outcome = value as Record; diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts index 5c59dd8b0e..b113d376fc 100644 --- a/packages/runtime-host/src/server/shared-session-transcript.ts +++ b/packages/runtime-host/src/server/shared-session-transcript.ts @@ -136,6 +136,7 @@ export function projectSharedSessionTranscriptMessage( ...(message.abortedAt === undefined ? {} : { abortedAt: message.abortedAt }), ...(message.abortSource === undefined ? {} : { abortSource: message.abortSource }), ...(message.errorClass === undefined ? {} : { errorClass: message.errorClass }), + ...(message.failureMessage === undefined ? {} : { failureMessage: message.failureMessage }), partialOutputRetained: message.partialOutputRetained, }; case 'token_usage': diff --git a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts index c789c6548a..aa03e448da 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -154,6 +154,7 @@ describe('ModelAdapter.startStream onError', () => { message: 'Rate limit exceeded', retryable: true, retryAfterMs: 2500, + diagnosticSummary: 'rate limited (status=429)', }, ]); const outcome = await requireAlreadySettled(result.outcome); @@ -204,6 +205,7 @@ describe('ModelAdapter.startStream onError', () => { kind: 'unknown', message: 'Plaintext Responses reasoning item is missing final summary metadata', retryable: false, + diagnosticSummary: 'Plaintext Responses reasoning item is missing final summary metadata', }, ]); const outcome = await requireAlreadySettled(result.outcome); @@ -249,6 +251,7 @@ describe('ModelAdapter.startStream onError', () => { retryable: false, message: 'Rate limit exceeded', code: 'rate_limit_exceeded', + diagnosticSummary: 'Provider stopped the stream with an error (code=rate_limit_exceeded)', }); assert.equal(outcome.usage?.rawFinishReason, 'rate_limit_exceeded'); }); @@ -288,6 +291,7 @@ describe('ModelAdapter.startStream onError', () => { message: 'Rate limit exceeded', retryable: true, retryAfterMs: 2500, + diagnosticSummary: 'rate limited (status=429)', }, ]); assert.deepEqual(await result.outcome, { @@ -485,6 +489,8 @@ describe('ModelAdapter.startStream onError', () => { kind: 'network', message: 'Network error', retryable: true, + diagnosticSummary: + 'Client network socket disconnected before secure TLS connection was established', }, ]); } finally { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index b6457dcbef..438ee5b952 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -402,6 +402,7 @@ describe('ModelAdapter stream and error normalization', () => { code: '429', message: 'Rate limit exceeded', retryable: false, + diagnosticSummary: '429 rate limit (code=429)', }); // The backend consumes the typed failure without recovering the raw // provider error shape. @@ -409,6 +410,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(shaped.reason, 'rate_limit'); assert.equal(shaped.code, '429'); assert.equal(shaped.message, 'Rate limit exceeded'); + assert.deepEqual(shaped.details, { providerSummary: '429 rate limit (code=429)' }); }); test('normalizes a status-less provider server_error into a retryable outage', () => { @@ -432,6 +434,8 @@ describe('ModelAdapter stream and error normalization', () => { code: 'server_error', message: 'Provider returned an error', retryable: true, + diagnosticSummary: + 'Streaming response failed: [502] Upstream error from Nvidia: Service temporarily overloaded (code=server_error)', }, }); }); @@ -937,6 +941,24 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.message.includes('sk-live-secret-token-value'), false); }); + test('re-scrubs a pre-normalized provider diagnostic at the event boundary', () => { + const event = newAdapter().makeErrorEvent('turn-1', { + type: 'model_failure', + kind: 'rate_limit', + message: 'Rate limit exceeded', + retryable: false, + diagnosticSummary: `provider says api_key=sk-live-secret-token-value ${'x'.repeat(4_000)}`, + }); + + const providerSummary = + event.details && !Array.isArray(event.details) ? event.details.providerSummary : undefined; + assert.equal( + typeof providerSummary === 'string' && providerSummary.includes('sk-live-secret-token-value'), + false, + ); + assert.equal(Buffer.byteLength(String(providerSummary ?? ''), 'utf8') <= 2 * 1024, true); + }); + test('normalizes cache and reasoning usage variants in the adapter module', () => { assert.deepEqual( normalizeAiSdkUsage({ diff --git a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts index 36052d5dd5..f760447cca 100644 --- a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts @@ -136,6 +136,8 @@ describe('OpenAI Responses ModelAdapter continuation', () => { retryable: true, code: 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', message: 'Network error', + diagnosticSummary: + 'closed before completion (code=OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR)', }, ]); }); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index c47760680f..e103c1afb0 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1636,6 +1636,36 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.deepStrictEqual(out.diagnostics, []); }); + test('carries the bounded provider summary into the durable failed turn', () => { + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-provider-failed', + ts: ts + 9, + status: 'failed', + content: { + kind: 'error', + reason: 'rate_limit', + message: 'Rate limit exceeded', + details: { + providerSummary: `provider says api_key=sk-live-secret ${'x'.repeat(1_000)}`, + }, + }, + actions: { endInvocation: true }, + }), + ], + { + invocations: [endedAs('failed', 'rate_limit')], + }, + ); + + const state = out.messages.find((message) => message.type === 'turn_state'); + assert.equal(state?.type, 'turn_state'); + assert.equal(state?.errorClass, 'rate_limit'); + assert.equal(state?.failureMessage?.includes('sk-live-secret'), false); + assert.ok(Buffer.byteLength(state?.failureMessage ?? '', 'utf8') <= 256); + }); + test('tool step cap terminal fact projects a persistent system notice', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index ff919edf1d..3f26c1bfff 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -82,6 +82,7 @@ import { buildStatusPatch, isTerminalRunStatus, normalizeStopSessionSource, + providerFailureMessageFromEvent, statusFromEvent, turnStatusFromEvent, } from './session-projection-helpers.js'; @@ -122,7 +123,12 @@ export interface AgentRunHooks { turnId: string, status: TurnRecord['status'], lineage?: AgentRunLineage, - options?: { ts?: number; errorClass?: string; abortSource?: string }, + options?: { + ts?: number; + errorClass?: string; + failureMessage?: string; + abortSource?: string; + }, ): Promise; } @@ -906,6 +912,7 @@ export class AgentRun { { ts: ev.ts, errorClass: turnStatus.errorClass, + failureMessage: turnStatus.failureMessage, ...(turnStatus.status === 'aborted' && this.abortSource ? { abortSource: this.abortSource } : {}), @@ -930,10 +937,14 @@ export class AgentRun { .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { ts: ev.ts, errorClass: ev.reason ?? ev.code ?? 'unknown', + failureMessage: ev.type === 'error' ? providerFailureMessageFromEvent(ev) : undefined, }) .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); - this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message); + this.markRunFailed( + ev.reason ?? ev.code ?? 'unknown', + (ev.type === 'error' ? providerFailureMessageFromEvent(ev) : undefined) ?? ev.message, + ); } } } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 83a62a01b0..f4aa2d5ac9 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -25,7 +25,8 @@ import { type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; -import { generalizedErrorMessage } from '@maka/core/redaction'; +import { generalizedErrorMessage, redactSecrets } from '@maka/core/redaction'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; import type { @@ -63,6 +64,7 @@ import { import { classifyError, errorPresentationFromClass, + PROVIDER_FAILURE_SUMMARY_MAX_BYTES, providerFailureSummary, providerRetryMetadata, } from './provider-error-classification.js'; @@ -541,6 +543,14 @@ export class ModelAdapter { makeErrorEvent(turnId: string, err: unknown, reasonOverride?: string): ErrorEvent { const failure = normalizeModelFailure(err); + const diagnosticSummary = + isModelFailure(err) && typeof err.diagnosticSummary === 'string' + ? truncateUtf8( + redactSecrets(err.diagnosticSummary), + PROVIDER_FAILURE_SUMMARY_MAX_BYTES, + '…', + ) + : undefined; return { type: 'error', id: this.input.newId(), @@ -554,6 +564,7 @@ export class ModelAdapter { ? { reason: failure.kind } : {}), message: failure.message, + ...(diagnosticSummary ? { details: { providerSummary: diagnosticSummary } } : {}), }; } @@ -1292,6 +1303,7 @@ function normalizeProviderFailure(error: unknown): ModelFailure { return { ...failure, ...(summary?.code !== undefined ? { code: summary.code } : {}), + ...(summary?.message !== undefined ? { diagnosticSummary: summary.message } : {}), ...(failure.kind === 'unknown' && summary !== undefined ? { message: summary.message } : {}), }; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 68e8a32c85..1259d7d19b 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -349,6 +349,8 @@ export interface ModelFailure { /** Provider-requested delay for the next physical attempt, in milliseconds. */ retryAfterMs?: number; code?: string; + /** Bounded, redacted provider response retained for user diagnostics. */ + diagnosticSummary?: string; } /** diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index aaa73bbe9b..91c54369a9 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -139,7 +139,7 @@ interface ProviderFailureSummary { code?: string; } -const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; +export const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; const MAX_SAFE_TIMER_DELAY_MS = 2_147_483_647; diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 2b5ab6137c..7b88ccea8a 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -493,6 +493,16 @@ function terminalRuntimeEvent(input: { ...(abortSource !== undefined ? { abortSource } : {}), }, }, + ...(status === 'failed' && turnState?.failureMessage + ? { + content: { + kind: 'error' as const, + message: turnState.failureMessage, + ...(turnState.errorClass ? { reason: turnState.errorClass } : {}), + details: { providerSummary: turnState.failureMessage }, + }, + } + : {}), ...(turnState ? { refs: { storedMessageId: turnState.id } } : {}), }, }; diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index d3cbba2a6e..9c34c35233 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -22,6 +22,8 @@ import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/ import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { redactSecrets } from '@maka/core/redaction'; import { SANDBOX_BOUNDARY_REQUEST_STATUSES, validateSandboxBoundaryExpansion, @@ -46,6 +48,7 @@ const SETTLED_SANDBOX_BOUNDARY_STATUSES: readonly SettledSandboxBoundaryStatus[] SANDBOX_BOUNDARY_REQUEST_STATUSES.filter( (status): status is SettledSandboxBoundaryStatus => status !== 'pending', ); +const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; import type { CanonicalPermissionOutcomeRecord } from './interaction-authority.js'; import { isArchivedToolResultPlaceholder } from './tool-result-archive.js'; @@ -1172,6 +1175,7 @@ function projectTerminalTurnState( } const abortSource = status === 'aborted' ? abortSourceFromRuntime(event) : undefined; const failureClass = status === 'failed' ? failureClassFromRuntimeEvent(event) : undefined; + const failureMessage = status === 'failed' ? failureMessageFromRuntimeEvent(event) : undefined; const partialOutputRetained = messages.some( (message) => message.turnId === event.turnId && @@ -1194,6 +1198,7 @@ function projectTerminalTurnState( ...(status === 'aborted' ? { abortedAt: event.ts } : {}), ...(abortSource ? { abortSource } : {}), ...(status === 'failed' ? { errorClass: failureClass ?? 'unknown' } : {}), + ...(failureMessage ? { failureMessage } : {}), partialOutputRetained, }); if (failureClass === 'tool_step_cap_reached') { @@ -1211,6 +1216,17 @@ function projectTerminalTurnState( return true; } +function failureMessageFromRuntimeEvent(event: RuntimeEvent): string | undefined { + const content = event.content; + if (content?.kind !== 'error' || !content.details || Array.isArray(content.details)) { + return undefined; + } + const summary = content.details.providerSummary; + return typeof summary === 'string' && summary.length > 0 + ? truncateUtf8(redactSecrets(summary), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') + : undefined; +} + function attachPendingThinking( event: RuntimeEvent, state: ProjectionState, @@ -1606,6 +1622,7 @@ function semanticMessage(message: StoredMessage): unknown { abortedAt: message.abortedAt, abortSource: message.abortSource, errorClass: message.errorClass, + failureMessage: message.failureMessage, partialOutputRetained: message.partialOutputRetained, }; case 'system_note': diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d18cce8d65..e3353a7563 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2762,7 +2762,13 @@ export class RuntimeKernel implements RuntimeKernelLike { turnId: string, status: TurnRecord['status'], lineage: AgentRunLineage = {}, - options: { id?: string; ts?: number; errorClass?: string; abortSource?: string } = {}, + options: { + id?: string; + ts?: number; + errorClass?: string; + failureMessage?: string; + abortSource?: string; + } = {}, ): Promise { const ts = options.ts ?? this.deps.now(); await this.deps.store.appendMessage( @@ -2775,6 +2781,7 @@ export class RuntimeKernel implements RuntimeKernelLike { lineage, ...(options.abortSource ? { abortSource: options.abortSource } : {}), ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), + ...(options.failureMessage !== undefined ? { failureMessage: options.failureMessage } : {}), partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), }), ); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 91bd2d1021..542a55d346 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4300,7 +4300,12 @@ export class SessionManager { turnId: string, status: TurnRecord['status'], lineage: AgentRunLineage = {}, - options: { ts?: number; errorClass?: string; abortSource?: string } = {}, + options: { + ts?: number; + errorClass?: string; + failureMessage?: string; + abortSource?: string; + } = {}, ): Promise { const ts = options.ts ?? this.deps.now(); await this.deps.store.appendMessage( @@ -4313,6 +4318,7 @@ export class SessionManager { lineage, ...(options.abortSource ? { abortSource: options.abortSource } : {}), ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), + ...(options.failureMessage !== undefined ? { failureMessage: options.failureMessage } : {}), partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), }), ); diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 456f0ddd7a..32383bedd3 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -18,6 +18,8 @@ */ import { createHash } from 'node:crypto'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { redactSecrets } from '@maka/core/redaction'; import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { @@ -47,6 +49,7 @@ export interface BuildTurnStateMessageInput { status: TurnRecord['status']; lineage?: TurnStateLineage; errorClass?: string; + failureMessage?: string; abortSource?: string; partialOutputRetained: boolean; } @@ -81,6 +84,9 @@ export function buildTurnStateMessage(input: BuildTurnStateMessageInput): TurnSt ...(input.status === 'aborted' ? { abortedAt: input.ts } : {}), ...(input.status === 'aborted' && input.abortSource ? { abortSource: input.abortSource } : {}), ...(input.status === 'failed' ? { errorClass: input.errorClass ?? 'unknown' } : {}), + ...(input.status === 'failed' && input.failureMessage + ? { failureMessage: input.failureMessage } + : {}), partialOutputRetained: input.partialOutputRetained, }; } @@ -164,12 +170,18 @@ export function statusFromEvent( export function turnStatusFromEvent( event: SessionEvent, -): { status: TurnRecord['status']; errorClass?: string } | undefined { +): { status: TurnRecord['status']; errorClass?: string; failureMessage?: string } | undefined { switch (event.type) { case 'abort': return { status: 'aborted' }; case 'error': - return { status: 'failed', errorClass: event.reason ?? event.code ?? 'unknown' }; + return { + status: 'failed', + errorClass: event.reason ?? event.code ?? 'unknown', + ...(providerFailureMessageFromEvent(event) + ? { failureMessage: providerFailureMessageFromEvent(event) } + : {}), + }; case 'complete': { if (event.stopReason === 'user_stop') return { status: 'aborted' }; const errorClass = failureClassFromCompleteStopReason(event.stopReason); @@ -181,6 +193,17 @@ export function turnStatusFromEvent( } } +/** Extract the adapter's bounded provider summary without exposing arbitrary details. */ +export function providerFailureMessageFromEvent( + event: Extract, +): string | undefined { + if (!event.details || Array.isArray(event.details)) return undefined; + const summary = event.details.providerSummary; + return typeof summary === 'string' && summary.length > 0 + ? truncateUtf8(redactSecrets(summary), 256, '…') + : undefined; +} + function blockedReasonFromErrorReason(reason: string | undefined): SessionBlockedReason { if (!reason) return 'unknown'; if (reason === 'permission_required') return 'permission_required'; diff --git a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts index 2583e6c095..b871fd625e 100644 --- a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts +++ b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts @@ -71,3 +71,31 @@ test('renders steering where it arrived in the assistant timeline', () => { assert.equal(visibleText.split(text).length - 1, 1, `${text} should render exactly once`); } }); + +test('renders provider failure detail collapsed beneath the classified banner', () => { + const turn: TurnViewModel = { + turnId: 'turn-provider-failure', + status: 'failed', + partialOutputRetained: false, + user: { id: 'user', role: 'user', text: 'request', ts: 1 }, + tools: [], + notes: [], + startedAt: 1, + timeline: [{ kind: 'text', text: 'partial output', messageId: 'assistant', ts: 2 }], + }; + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(TurnView, { + turn, + failedReasonLabel: 'Rate limit exceeded', + failedDiagnostic: '429 rate limit (code=rate_limit_exceeded, requestId=req-123)', + }), + }), + ); + assert.match(markup, /]*maka-turn-failed-diagnostic/); + assert.match(markup, /Provider response details<\/summary>/); + assert.match(markup, /429 rate limit/); + assert.doesNotMatch(markup, /]*open/); + assert.ok(markup.indexOf('Rate limit exceeded') < markup.indexOf('429 rate limit')); +}); diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index a7a2a76a3a..fdb9aad80a 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -485,6 +485,24 @@ describe("unfinished tools take their status from the turn", () => { ]); assert.equal(turn?.tools[0]?.status, "interrupted"); }); + + test("restores provider failure detail from a reloaded turn state", () => { + const [turn] = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "failed", + errorClass: "rate_limit", + failureMessage: "provider says retry after 30 seconds", + partialOutputRetained: false, + }, + ]); + assert.equal(turn?.errorClass, "rate_limit"); + assert.equal(turn?.failureMessage, "provider says retry after 30 seconds"); + }); }); describe("live tool status over persisted", () => { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 69cc381b8e..0976dc30da 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -397,6 +397,8 @@ export const TurnView = memo(function TurnView(props: { * is the outcome, this is the execution state, and both can be true. */ failedExecutionStateLabel?: string; + /** Bounded provider response detail shown collapsed beneath the category. */ + failedDiagnostic?: string; safeResumeAction?: { pending: boolean; detail?: string; @@ -683,35 +685,43 @@ export const TurnView = memo(function TurnView(props: { is one — it explains why the button did nothing, which outranks execution state on the one turn that can have both. */} {ownsTurnChrome && turn.status === 'failed' && props.failedReasonLabel && ( - - ), - } - : {})} - /> + <> + + ), + } + : {})} + /> + {props.failedDiagnostic && ( +
+ {copy.failedDiagnostic} +
{props.failedDiagnostic}
+
+ )} + )} {ownsTurnChrome && props.liveStreaming && ( <> diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0e2e146298..ba5abac351 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -828,6 +828,9 @@ export function ChatView(props: { failedExecutionStateLabel={ turnPresentation?.failedExecutionStateLabels[turn.turnId] } + failedDiagnostic={turnPresentation?.failedReasonLabels[turn.turnId] + ? turn.failureMessage + : undefined} safeResumeAction={turnPresentation?.resumeCandidateTurnId === turn.turnId ? props.safeResumeAction : undefined} diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 12ed2c76d6..41cb62ebec 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -291,6 +291,7 @@ export interface ConversationCopy { providerRetryReason: Record; safeResumePending: string; safeResume: string; + failedDiagnostic: string; thinking: string; truncated: string; copied: string; @@ -549,6 +550,7 @@ const CONVERSATION_COPY = { }, messages: { you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', awaitingModelOutput: '等待模型输出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, 'zh-CN')}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', + failedDiagnostic: 'Provider 响应详情', editMessageDisabledDirectoryReferences: '包含文件夹引用的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', @@ -879,6 +881,7 @@ const CONVERSATION_COPY = { }, messages: { you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', awaitingModelOutput: 'Waiting for model output…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, 'en')} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages with expanded context', + failedDiagnostic: 'Provider response details', editMessageDisabledDirectoryReferences: 'Edit & resend does not yet support messages with folder references', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button', diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index e55f6283e9..3f0f4ec5e1 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -415,6 +415,7 @@ export interface TurnViewModel { abortedAt?: number; abortSource?: string; errorClass?: string; + failureMessage?: string; partialOutputRetained: boolean; user?: ChatItem; tools: ToolActivityItem[]; @@ -742,6 +743,7 @@ export function materializeTurns( : {}), ...(record?.abortSource ? { abortSource: record.abortSource } : {}), ...(record?.errorClass ? { errorClass: record.errorClass } : {}), + ...(record?.failureMessage ? { failureMessage: record.failureMessage } : {}), partialOutputRetained: record?.partialOutputRetained ?? false, tools: [], notes: [], diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index fc9159377e..7bd20993ea 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -805,6 +805,19 @@ /* Astryx `Banner` paints the failed-turn surface; this only sets the block rhythm between it and the timeline entry above it. */ .maka-turn-failed-banner { margin-block: var(--space-1) var(--space-0-5); } +.maka-turn-failed-diagnostic { + margin-block: var(--space-0-5) var(--space-1); + padding-inline: var(--space-1); + color: var(--foreground-secondary); + font-size: 0.8rem; +} +.maka-turn-failed-diagnostic summary { cursor: pointer; } +.maka-turn-failed-diagnostic pre { + margin-block: var(--space-0-5) 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; +} .maka-turn-lineage-row, .maka-turn-footer { From 3bd59fe866ec02e8e781f123da22afa467e1a613 Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 11:41:48 +0800 Subject: [PATCH 02/12] fix(ui): use current muted foreground token Generated-by: Codex --- packages/ui/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 7bd20993ea..ec8274a0ac 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -808,7 +808,7 @@ .maka-turn-failed-diagnostic { margin-block: var(--space-0-5) var(--space-1); padding-inline: var(--space-1); - color: var(--foreground-secondary); + color: var(--muted-foreground); font-size: 0.8rem; } .maka-turn-failed-diagnostic summary { cursor: pointer; } From 317d9ef32ee41241ae13195ea40913e278c9b436 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 10:22:24 +0800 Subject: [PATCH 03/12] fix(protocol): advance diagnostics compatibility epoch Generated-by: OpenAI Codex --- packages/runtime-host/src/protocol/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 12740d8864..6b40e9a175 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Session Turn projections carry bounded provider failure summaries for +// live and reloaded failed-turn diagnostics. Older peers cannot preserve or +// render this additional failure context safely. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. @@ -138,9 +141,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; // canonical OAuth material with an empty enable-all-discovered selection. // Older peers reject these wire values, so incompatible pairs must fail the // handshake. Re-derived from current `main`; epoch 102 is claimed by open PRs. -// 110: Session Turn projections carry bounded provider failure summaries for -// live and reloaded failed-turn diagnostics. Older peers cannot preserve or -// render this additional failure context safely. // 101: Session Turn requests can carry regeneration intents and Guests can // atomically withdraw pending requests. Older peers do not share this command // vocabulary or the expanded Guest operation grant. From 2892213f2caeb08b83c3c1409549a7488b0ac785 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 15:56:00 +0800 Subject: [PATCH 04/12] test(desktop): cover provider failure diagnostics Generated-by: Codex --- apps/desktop/e2e/fixtures.ts | 10 ++++++ .../e2e/provider-failure-diagnostic.spec.ts | 36 +++++++++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 6 +++- .../src/main/e2e-fixture/scenarios-chat.ts | 24 +++++++++++++ packages/core/src/e2e-fixture.ts | 1 + 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/e2e/provider-failure-diagnostic.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 42ce777aca..3dd01889f5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -573,6 +573,7 @@ type E2eTestFixtures = { promptRailWindow: Page; threadSearchWindow: Page; partialHistoryWindow: Page; + providerFailureWindow: Page; requestHeaderRowWindow: Page; permissionCenterWindow: Page; newTaskTargetWindow: Page; @@ -777,6 +778,15 @@ export const test = base.extend({ showWindow: true, }, use); }, + providerFailureWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '.maka-turn-failed-diagnostic', + e2eFixtureScenario: 'chat-provider-failure', + locale: 'zh', + showWindow: true, + }, use); + }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/provider-failure-diagnostic.spec.ts b/apps/desktop/e2e/provider-failure-diagnostic.spec.ts new file mode 100644 index 0000000000..95b3d257c7 --- /dev/null +++ b/apps/desktop/e2e/provider-failure-diagnostic.spec.ts @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test } from './fixtures.js'; + +test('provider failure detail is collapsed until the user expands it', async ({ + providerFailureWindow: page, +}) => { + const diagnostic = page.locator('.maka-turn-failed-diagnostic'); + await expect(diagnostic).not.toHaveAttribute('open', ''); + await expect(diagnostic.getByText('Provider 响应详情', { exact: true })).toBeVisible(); + await expect(diagnostic.locator('pre')).not.toBeVisible(); + + await diagnostic.locator('summary').click(); + + await expect(diagnostic).toHaveAttribute('open', ''); + await expect(diagnostic.locator('pre')).toHaveText( + 'Provider returned 429: request rate limit reached. Please retry after 30 seconds.', + ); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 6c741c5248..d2252c4676 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -51,6 +51,7 @@ import { partialHistorySession, promptRailMessages, promptRailSession, + providerFailureMessages, turnMessages, turnSession, agentGraphSession, @@ -71,6 +72,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative-browser', 'chat-prompt-rail', 'chat-partial-history', + 'chat-provider-failure', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -191,6 +193,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'chat-partial-history': return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; + case 'chat-provider-failure': + return { ...state, activeSessionId: TURN_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -242,7 +246,7 @@ export async function seedE2eFixture(input: { await writeSession( input.workspaceRoot, scenario === 'agent-graph-layout' ? agentGraphSession(now) : turnSession(now), - turnMessages(now), + scenario === 'chat-provider-failure' ? providerFailureMessages(now) : turnMessages(now), ); if (scenario === 'agent-graph-layout') await seedAgentGraphLayout(input.workspaceRoot, now); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index fb26506f70..b07a222196 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -120,6 +120,30 @@ export function turnMessages(now: number): StoredMessage[] { ]; } +export function providerFailureMessages(now: number): StoredMessage[] { + const turnId = 'turn-provider-failure'; + return [ + { + type: 'user', + id: 'msg-provider-failure-user', + turnId, + ts: now - 60_000, + text: '请总结今天的项目进展。', + }, + { + type: 'turn_state', + id: 'state-provider-failure', + turnId, + ts: now - 59_000, + status: 'failed', + errorClass: 'rate_limit', + failureMessage: + 'Provider returned 429: request rate limit reached. Please retry after 30 seconds.', + partialOutputRetained: false, + }, + ]; +} + export function promptRailSession(now: number): SessionHeader { return header({ id: PROMPT_RAIL_SESSION_ID, diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 574e60548c..43bf2556fd 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -28,6 +28,7 @@ export type E2eFixtureScenario = | 'turn-narrative-browser' | 'chat-prompt-rail' | 'chat-partial-history' + | 'chat-provider-failure' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' From 27ea4d83f3a16b73f1426f17b36cce84f080b9f7 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:20:37 +0800 Subject: [PATCH 05/12] ci: retry unrelated flaky checks From eab85add61260080467f2a1c122281c5b3e5bb7a Mon Sep 17 00:00:00 2001 From: testikun Date: Sat, 5 Sep 2026 10:08:52 +0800 Subject: [PATCH 06/12] fix(runtime): preserve classified provider failures --- packages/core/src/diagnostic-log.ts | 3 ++ .../src/__tests__/session-projector.test.ts | 40 +++++++++++++++++++ .../src/adapter/session-projector.ts | 6 +-- .../src/protocol/session-turns.ts | 4 +- packages/runtime-host/src/protocol/turn.ts | 3 +- packages/runtime/src/agent-run.ts | 5 +-- .../runtime/src/runtime-event-backfill.ts | 2 +- .../runtime/src/runtime-event-read-model.ts | 3 +- .../runtime/src/session-projection-helpers.ts | 12 +++--- 9 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/core/src/diagnostic-log.ts b/packages/core/src/diagnostic-log.ts index 09e0077f38..3988ce2923 100644 --- a/packages/core/src/diagnostic-log.ts +++ b/packages/core/src/diagnostic-log.ts @@ -19,6 +19,9 @@ import { redactSecrets } from './redaction.js'; +/** Shared byte budget for provider diagnostics carried in Turn state. */ +export const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; + export type DiagnosticLogLevel = 'debug' | 'info' | 'log' | 'warn' | 'error'; interface DiagnosticLogEntry { diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 01fba0aa9f..7acc2e9088 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -388,6 +388,46 @@ test('projects structured context-budget failure detail to the Desktop event', ( ]); }); +test('keeps the classified failure message separate from provider diagnostics', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'rate_limit', + failureMessage: '429 from provider (requestId=req-123)', + }, + }), + }).events; + + assert.deepEqual(events, [ + { + type: 'error', + id: 'terminal-1', + turnId: 'turn-1', + ts: 10, + recoverable: false, + reason: 'rate_limit', + message: 'Turn failed: rate_limit', + details: { providerSummary: '429 from provider (requestId=req-123)' }, + }, + ]); +}); + test('reseeds a scheduled retry with remainingMs recomputed from the stored schedule time', () => { // #3393: a reconnect mid-wait must not restart the countdown. The snapshot // keeps the host-clock schedule time; the projector re-derives the skew-free diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 12cfa15c00..8823265ab4 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -290,7 +290,7 @@ export class RuntimeHostSessionProjector { ts: terminal.ts, recoverable: false, reason, - message: terminal.failureMessage ?? `Turn failed: ${reason}`, + message: `Turn failed: ${reason}`, ...(terminal.failureMessage ? { details: { providerSummary: terminal.failureMessage } } : {}), @@ -332,7 +332,7 @@ export class RuntimeHostSessionProjector { ts, recoverable: false, reason, - message: turn.failureMessage ?? `Turn failed: ${reason}`, + message: `Turn failed: ${reason}`, ...(turn.failureMessage ? { details: { providerSummary: turn.failureMessage } } : {}), }, ]; @@ -489,7 +489,7 @@ export class RuntimeHostSessionProjector { ts: this.#now(), recoverable: false, reason: root.failureClass, - message: root.failureMessage ?? `Turn failed: ${root.failureClass}`, + message: `Turn failed: ${root.failureClass}`, ...(root.failureMessage ? { details: { providerSummary: root.failureMessage } } : {}), }); } else { diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index b3c0df009d..e1f523de6a 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -17,8 +17,8 @@ * under the License. */ +import { TURN_FAILURE_MESSAGE_MAX_BYTES, truncateUtf8 } from '@maka/core/diagnostic-log'; import { decodeCanonicalMessage, type TurnRecord, type TurnStateMessage } from '@maka/core/session'; -import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { requireCount, requireEncodedByteLimit, @@ -32,7 +32,7 @@ import { defineOperation } from './operation-spec.js'; export const SESSION_TURN_QUERY_MAX_CONTRIBUTIONS = 128; export const SESSION_TURN_QUERY_RESULT_MAX_BYTES = 192 * 1024; export const SESSION_TURN_DIAGNOSTIC_MAX_BYTES = 128; -export const SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES = 256; +export const SESSION_TURN_FAILURE_MESSAGE_MAX_BYTES = TURN_FAILURE_MESSAGE_MAX_BYTES; export const SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES = 256; export const SESSION_TURN_LANDMARK_MAX_ITEMS = 64; export const SESSION_TURN_LANDMARK_LABEL_MAX_BYTES = 96; diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..cd24e11970 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -18,6 +18,7 @@ */ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { TURN_FAILURE_MESSAGE_MAX_BYTES as CORE_TURN_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/diagnostic-log'; import { decodeMessageContent as decodeCanonicalMessageContent, DIRECTORY_REFERENCE_MAX_COUNT, @@ -48,7 +49,7 @@ import { } from './codec.js'; import { defineOperation } from './operation-spec.js'; -export const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; +export const TURN_FAILURE_MESSAGE_MAX_BYTES = CORE_TURN_FAILURE_MESSAGE_MAX_BYTES; export interface TurnStartInput { sessionId: string; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 3f26c1bfff..a1a37d02e2 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -941,10 +941,7 @@ export class AgentRun { }) .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); - this.markRunFailed( - ev.reason ?? ev.code ?? 'unknown', - (ev.type === 'error' ? providerFailureMessageFromEvent(ev) : undefined) ?? ev.message, - ); + this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message); } } } diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 7b88ccea8a..8e98c9719d 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -497,7 +497,7 @@ function terminalRuntimeEvent(input: { ? { content: { kind: 'error' as const, - message: turnState.failureMessage, + message: `Turn failed: ${turnState.errorClass ?? failureClass ?? 'unknown'}`, ...(turnState.errorClass ? { reason: turnState.errorClass } : {}), details: { providerSummary: turnState.failureMessage }, }, diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 9c34c35233..cfdd1af3e1 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -22,7 +22,7 @@ import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/ import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; -import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { TURN_FAILURE_MESSAGE_MAX_BYTES, truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import { SANDBOX_BOUNDARY_REQUEST_STATUSES, @@ -48,7 +48,6 @@ const SETTLED_SANDBOX_BOUNDARY_STATUSES: readonly SettledSandboxBoundaryStatus[] SANDBOX_BOUNDARY_REQUEST_STATUSES.filter( (status): status is SettledSandboxBoundaryStatus => status !== 'pending', ); -const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; import type { CanonicalPermissionOutcomeRecord } from './interaction-authority.js'; import { isArchivedToolResultPlaceholder } from './tool-result-archive.js'; diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 32383bedd3..4fa4a01908 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -18,7 +18,7 @@ */ import { createHash } from 'node:crypto'; -import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { TURN_FAILURE_MESSAGE_MAX_BYTES, truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; @@ -174,14 +174,14 @@ export function turnStatusFromEvent( switch (event.type) { case 'abort': return { status: 'aborted' }; - case 'error': + case 'error': { + const failureMessage = providerFailureMessageFromEvent(event); return { status: 'failed', errorClass: event.reason ?? event.code ?? 'unknown', - ...(providerFailureMessageFromEvent(event) - ? { failureMessage: providerFailureMessageFromEvent(event) } - : {}), + ...(failureMessage ? { failureMessage } : {}), }; + } case 'complete': { if (event.stopReason === 'user_stop') return { status: 'aborted' }; const errorClass = failureClassFromCompleteStopReason(event.stopReason); @@ -200,7 +200,7 @@ export function providerFailureMessageFromEvent( if (!event.details || Array.isArray(event.details)) return undefined; const summary = event.details.providerSummary; return typeof summary === 'string' && summary.length > 0 - ? truncateUtf8(redactSecrets(summary), 256, '…') + ? truncateUtf8(redactSecrets(summary), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') : undefined; } From bccf946cf12a73a3c13ec9767a88bfecec12ba9c Mon Sep 17 00:00:00 2001 From: testikun Date: Sat, 5 Sep 2026 10:21:50 +0800 Subject: [PATCH 07/12] fix(ui): complete provider diagnostic copy --- packages/ui/src/conversation-copy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 41cb62ebec..cac98c5d32 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -703,6 +703,7 @@ const CONVERSATION_COPY = { }, messages: { you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', awaitingModelOutput: '等待模型輸出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, 'zh-TW')}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', + failedDiagnostic: 'Provider 回應詳情', editMessageDisabledDirectoryReferences: '包含資料夾引用的歷史訊息暫不支援編輯並重發', userAriaLabel: '你傳送的訊息', systemAriaLabel: '系統訊息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}訊息${context ? `:${context}` : ''}`, sourceAriaLabel: '本輪迴答的來源', derivativesAriaLabel: '本輪迴答的衍生', scheduledTaskTriggered: '定時任務觸發', scheduledTaskTitle: (id) => `由定時任務觸發 · ${id}`, legacyAutomationTriggered: '舊版自動化(僅歷史)', legacyAutomationTitle: (id) => `由舊版自動化觸發 · ${id} · 僅保留歷史,不會再次執行`, goalContinued: 'Goal 自動繼續', goalTitle: (id) => `由 Goal 繼續執行 · ${id}`, agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截斷;顯示的是最近的內容', outputTruncatedTitle: '助手輸出已超過單次回合上限,超出部分未渲染。如需完整內容請重新生成或檢視持久化的任務記錄。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展開引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中斷)', abortedByStop: '(已中斷 · 由停止按鈕觸發)', From ea0b3a1d9f87e7294fa75af2ef6538b2d6654002 Mon Sep 17 00:00:00 2001 From: testikun Date: Sat, 5 Sep 2026 11:32:50 +0800 Subject: [PATCH 08/12] test(desktop): use supported locale for provider diagnostics --- apps/desktop/e2e/fixtures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 3dd01889f5..df33886019 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -783,7 +783,7 @@ export const test = base.extend({ seed: false, readinessSelector: '.maka-turn-failed-diagnostic', e2eFixtureScenario: 'chat-provider-failure', - locale: 'zh', + locale: 'zh-CN', showWindow: true, }, use); }, From 185bd30a8f44907f31c6d57307eee4f12fc81357 Mon Sep 17 00:00:00 2001 From: testikun Date: Sat, 5 Sep 2026 11:50:02 +0800 Subject: [PATCH 09/12] ci: retry full checks for provider diagnostics From 60e591f23eaaf79bdea656db054984ae946cf20e Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 11:57:20 +0800 Subject: [PATCH 10/12] ci: retry unrelated quote-selection flaky test From e882a04a943107d2009143879a149106730f96b0 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sat, 5 Sep 2026 14:03:29 +0800 Subject: [PATCH 11/12] fix(runtime): share provider diagnostic projection --- packages/core/src/diagnostic-log.ts | 8 ++++++++ .../src/server/canonical-turn-snapshot.ts | 12 ++++++------ packages/runtime/src/runtime-event-read-model.ts | 11 ++--------- packages/runtime/src/session-projection-helpers.ts | 9 ++------- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/core/src/diagnostic-log.ts b/packages/core/src/diagnostic-log.ts index 3988ce2923..4de8acd8d2 100644 --- a/packages/core/src/diagnostic-log.ts +++ b/packages/core/src/diagnostic-log.ts @@ -22,6 +22,14 @@ import { redactSecrets } from './redaction.js'; /** Shared byte budget for provider diagnostics carried in Turn state. */ export const TURN_FAILURE_MESSAGE_MAX_BYTES = 256; +export function providerFailureSummaryFromDetails(details: unknown): string | undefined { + if (!details || Array.isArray(details) || typeof details !== 'object') return undefined; + const summary = (details as { providerSummary?: unknown }).providerSummary; + return typeof summary === 'string' && summary.length > 0 + ? truncateUtf8(redactSecrets(summary), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') + : undefined; +} + export type DiagnosticLogLevel = 'debug' | 'info' | 'log' | 'warn' | 'error'; interface DiagnosticLogEntry { diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 6d51515a0b..08bb621c43 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -18,9 +18,10 @@ */ import { type ContextCompactionOutcome } from '@maka/core/events'; -import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { providerFailureSummaryFromDetails, truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import { readRunInvocation } from '@maka/core/runtime-event-store'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { classifyTerminalRuntimeLedger } from '@maka/runtime/terminal-run-commit'; import type { ExecutionStoresWriter } from '@maka/storage/execution-stores'; @@ -125,12 +126,11 @@ async function hasPendingInteraction( } function providerFailureSummaryFromRuntimeEvent( - event: import('@maka/core/runtime-event').RuntimeEvent, + event: RuntimeEvent, ): string | undefined { - const details = event.content?.kind === 'error' ? event.content.details : undefined; - if (!details || Array.isArray(details)) return undefined; - const summary = details.providerSummary; - return typeof summary === 'string' && summary.length > 0 ? summary : undefined; + return providerFailureSummaryFromDetails( + event.content?.kind === 'error' ? event.content.details : undefined, + ); } function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { if (!value || typeof value !== 'object') return undefined; diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index cfdd1af3e1..897dd27573 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -22,8 +22,7 @@ import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/ import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; -import { TURN_FAILURE_MESSAGE_MAX_BYTES, truncateUtf8 } from '@maka/core/diagnostic-log'; -import { redactSecrets } from '@maka/core/redaction'; +import { providerFailureSummaryFromDetails } from '@maka/core/diagnostic-log'; import { SANDBOX_BOUNDARY_REQUEST_STATUSES, validateSandboxBoundaryExpansion, @@ -1217,13 +1216,7 @@ function projectTerminalTurnState( function failureMessageFromRuntimeEvent(event: RuntimeEvent): string | undefined { const content = event.content; - if (content?.kind !== 'error' || !content.details || Array.isArray(content.details)) { - return undefined; - } - const summary = content.details.providerSummary; - return typeof summary === 'string' && summary.length > 0 - ? truncateUtf8(redactSecrets(summary), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') - : undefined; + return content?.kind === 'error' ? providerFailureSummaryFromDetails(content.details) : undefined; } function attachPendingThinking( diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 4fa4a01908..bea3b19ffa 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -18,8 +18,7 @@ */ import { createHash } from 'node:crypto'; -import { TURN_FAILURE_MESSAGE_MAX_BYTES, truncateUtf8 } from '@maka/core/diagnostic-log'; -import { redactSecrets } from '@maka/core/redaction'; +import { providerFailureSummaryFromDetails } from '@maka/core/diagnostic-log'; import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { @@ -197,11 +196,7 @@ export function turnStatusFromEvent( export function providerFailureMessageFromEvent( event: Extract, ): string | undefined { - if (!event.details || Array.isArray(event.details)) return undefined; - const summary = event.details.providerSummary; - return typeof summary === 'string' && summary.length > 0 - ? truncateUtf8(redactSecrets(summary), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') - : undefined; + return providerFailureSummaryFromDetails(event.details); } function blockedReasonFromErrorReason(reason: string | undefined): SessionBlockedReason { From 71a44dfbc2521e5e877c4bd30d3936e2197c20dd Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sat, 5 Sep 2026 14:09:17 +0800 Subject: [PATCH 12/12] style(runtime-host): format diagnostic helper signature --- packages/runtime-host/src/server/canonical-turn-snapshot.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 08bb621c43..ab12101aab 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -125,9 +125,7 @@ async function hasPendingInteraction( ); } -function providerFailureSummaryFromRuntimeEvent( - event: RuntimeEvent, -): string | undefined { +function providerFailureSummaryFromRuntimeEvent(event: RuntimeEvent): string | undefined { return providerFailureSummaryFromDetails( event.content?.kind === 'error' ? event.content.details : undefined, );