Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ type E2eTestFixtures = {
railRenderWindow: Page;
promptRailWindow: Page;
partialHistoryWindow: Page;
providerFailureWindow: Page;
requestHeaderRowWindow: Page;
newTaskTargetWindow: Page;
directoryReferenceWindow: { page: Page; folder: string };
Expand Down Expand Up @@ -763,6 +764,15 @@ export const test = base.extend<E2eTestFixtures, E2eWorkerFixtures>({
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
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/e2e/provider-failure-diagnostic.spec.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
});
6 changes: 5 additions & 1 deletion apps/desktop/src/main/e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
partialHistorySession,
promptRailMessages,
promptRailSession,
providerFailureMessages,
turnMessages,
turnSession,
agentGraphSession,
Expand All @@ -71,6 +72,7 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
'turn-narrative-browser',
'chat-prompt-rail',
'chat-partial-history',
'chat-provider-failure',
'settings-data',
'settings-bots-onboarding',
'settings-general',
Expand Down Expand Up @@ -189,6 +191,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':
Expand Down Expand Up @@ -240,7 +244,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);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1256,6 +1260,7 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape<TurnStateMessage>()(
'abortedAt',
'abortSource',
'errorClass',
'failureMessage',
],
);
const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)',
);
}
});
Expand Down
42 changes: 42 additions & 0 deletions packages/runtime-host/src/__tests__/session-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions packages/runtime-host/src/adapter/session-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ export class RuntimeHostSessionProjector {
ts: terminal.ts,
recoverable: false,
reason,
message: `Turn failed: ${reason}`,
message: terminal.failureMessage ?? `Turn failed: ${reason}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve provider-summary provenance and stable error semantics
TurnSnapshot.failureMessage is not necessarily provider-specific: readCanonicalTurnSnapshot() still falls back to the terminal event's generic content.message. Treating every value here as details.providerSummary means replayed internal/runtime failures can be presented as “Provider response details.” It also replaces the stable ErrorEvent.message with the diagnostic, whereas live ModelAdapter events keep the classified message in message and carry provider text only in details. Could we preserve provider-summary provenance in a separate field, keep the replayed message stable, and only populate providerSummary when it actually originated from the provider?

...(terminal.failureMessage
? { details: { providerSummary: terminal.failureMessage } }
: {}),
});
} else {
events.push({
Expand Down Expand Up @@ -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 } } : {}),
},
];
}
Expand Down Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,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 = 109 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 110 as const;
// 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.
// 109: accepted Client Capability invocations may carry one bounded nested form
// Interaction request/result round trip.
// 108: Session Interaction snapshots, forwarded Runtime events, and Agent Graph
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime-host/src/protocol/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion packages/runtime-host/src/server/canonical-turn-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
'…',
)
Expand Down Expand Up @@ -121,6 +124,14 @@ async function hasPendingInteraction(
);
}

function providerFailureSummaryFromRuntimeEvent(
event: import('@maka/core/runtime-event').RuntimeEvent,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This inline type query is valid and does not emit a runtime import, but the surrounding runtime-host server code consistently uses top-level type-only imports, including every other RuntimeEvent reference. Could we add import type { RuntimeEvent } from '@maka/core/runtime-event' and use event: RuntimeEvent here for consistency and readability?

): 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<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Loading