mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor(agent-core-v2): turn end reason
This commit is contained in:
parent
38c2c0f527
commit
c76fba4942
34 changed files with 278 additions and 113 deletions
|
|
@ -805,9 +805,12 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
|
|||
}
|
||||
|
||||
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
|
||||
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
|
||||
if (event.reason === 'filtered') {
|
||||
if (event.error?.code === 'provider.filtered') {
|
||||
return 'Provider safety policy blocked the response.';
|
||||
}
|
||||
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
|
||||
if (event.reason === 'blocked') {
|
||||
return 'Prompt hook blocked the request.';
|
||||
}
|
||||
return `Prompt turn ended with reason: ${event.reason}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,14 +196,17 @@ export class BtwPanelController {
|
|||
}
|
||||
|
||||
function formatBtwTurnEnd(event: TurnEndedEvent): string {
|
||||
if (event.error !== undefined) {
|
||||
return `[${event.error.code}] ${event.error.message}`;
|
||||
}
|
||||
if (event.reason === 'cancelled') {
|
||||
return 'Interrupted by user';
|
||||
}
|
||||
if (event.reason === 'filtered') {
|
||||
if (event.error?.code === 'provider.filtered') {
|
||||
return 'Provider safety policy blocked the response.';
|
||||
}
|
||||
if (event.error !== undefined) {
|
||||
return `[${event.error.code}] ${event.error.message}`;
|
||||
}
|
||||
if (event.reason === 'blocked') {
|
||||
return 'Prompt hook blocked the request.';
|
||||
}
|
||||
return `BTW turn ended with reason: ${event.reason}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,9 +333,12 @@ export class SessionEventHandler {
|
|||
if (event.reason === 'cancelled') {
|
||||
this.markActiveAgentSwarmsCancelled();
|
||||
}
|
||||
if (event.reason === 'filtered') {
|
||||
if (event.reason === 'failed' && event.error?.code === 'provider.filtered') {
|
||||
this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error');
|
||||
}
|
||||
if (event.reason === 'blocked') {
|
||||
this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error');
|
||||
}
|
||||
const todos = this.host.state.todoPanel.getTodos();
|
||||
if (todos.length > 0 && todos.every((t) => t.status === 'done')) {
|
||||
this.host.streamingUI.setTodoList([]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
isKimiError,
|
||||
type KimiErrorPayload,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import { isKimiError } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import {
|
||||
STREAMING_ARGS_FIELD_RE,
|
||||
|
|
@ -103,9 +100,13 @@ export function formatErrorMessage(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function formatErrorPayload(
|
||||
error: Pick<KimiErrorPayload, 'code' | 'message' | 'details'>,
|
||||
): string {
|
||||
interface ErrorPayloadLike {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function formatErrorPayload(error: ErrorPayloadLike): string {
|
||||
const filteredMessage = formatProviderFilteredMessage(error.details);
|
||||
if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`;
|
||||
return `[${error.code}] ${error.message}`;
|
||||
|
|
|
|||
|
|
@ -862,7 +862,13 @@ describe('runPrompt', () => {
|
|||
mocks.mainEvent({
|
||||
type: 'turn.ended',
|
||||
turnId: 2,
|
||||
reason: 'filtered',
|
||||
reason: 'failed',
|
||||
error: {
|
||||
code: 'provider.filtered',
|
||||
message: 'Provider safety policy blocked the response.',
|
||||
name: 'ProviderFilteredError',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -930,7 +930,7 @@ export function createAgentProjector(): AgentProjector {
|
|||
sessionId,
|
||||
messageId: msgId,
|
||||
content: msg.content.map((c) => ({ ...c })),
|
||||
status: reason === 'failed' || reason === 'filtered' ? 'error' : 'completed',
|
||||
status: reason === 'failed' || reason === 'blocked' ? 'error' : 'completed',
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
|
@ -941,7 +941,9 @@ export function createAgentProjector(): AgentProjector {
|
|||
out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot });
|
||||
|
||||
const newStatus =
|
||||
reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle';
|
||||
reason === 'cancelled' || reason === 'failed' || reason === 'blocked'
|
||||
? 'aborted'
|
||||
: 'idle';
|
||||
out.push({
|
||||
type: 'sessionStatusChanged',
|
||||
sessionId,
|
||||
|
|
|
|||
|
|
@ -56,21 +56,27 @@ export function assistantDeltaToSessionUpdate(
|
|||
* belong on the JSON-RPC error channel). Returning `end_turn` keeps the
|
||||
* client unblocked; the caller is expected to log the `error` payload
|
||||
* separately so the failure is observable in the agent logs.
|
||||
* `filtered` → `refusal`: the provider's safety policy blocked the
|
||||
* response. ACP's `refusal` stop reason is the native signal for a
|
||||
* model/provider decline, so the client can render the block instead of
|
||||
* mistaking it for a clean `end_turn`. The caller additionally logs the
|
||||
* block so it stays observable in the agent logs.
|
||||
* `failed` + `provider.filtered` → `refusal`: the provider's safety policy
|
||||
* blocked the response. ACP's `refusal` stop reason is the native signal
|
||||
* for a model/provider decline, so the client can render the block instead
|
||||
* of mistaking it for a clean `end_turn`.
|
||||
* `blocked` → `refusal`: a prompt hook blocked the turn before the model
|
||||
* ran. ACP has no separate hook-blocked terminal state, so reuse the
|
||||
* refusal channel instead of reporting a clean `end_turn`.
|
||||
*/
|
||||
export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason {
|
||||
export function turnEndReasonToStopReason(
|
||||
reason: TurnEndReason,
|
||||
error?: { readonly code: string },
|
||||
): AcpStopReason {
|
||||
switch (reason) {
|
||||
case 'completed':
|
||||
return 'end_turn';
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
case 'failed':
|
||||
if (error?.code === 'provider.filtered') return 'refusal';
|
||||
return 'end_turn';
|
||||
case 'filtered':
|
||||
case 'blocked':
|
||||
return 'refusal';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
type BackgroundTaskInfo,
|
||||
type ContextMessage,
|
||||
type Event,
|
||||
type KimiErrorPayload,
|
||||
type KimiHarness,
|
||||
type McpServerInfo,
|
||||
type QuestionAnswers,
|
||||
|
|
@ -1151,12 +1150,15 @@ export class AcpSession {
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
if (event.reason === 'filtered') {
|
||||
// The provider's safety policy blocked the response. It is
|
||||
// mapped to ACP `refusal` (see turnEndReasonToStopReason); log
|
||||
// it here too so the block stays observable in the agent logs,
|
||||
// mirroring the `failed` branch above.
|
||||
log.warn('acp: turn ended with filtered reason', { sessionId });
|
||||
if (event.reason === 'blocked') {
|
||||
// Provider safety and prompt hooks both map to ACP `refusal`
|
||||
// (see turnEndReasonToStopReason); log them here too so the
|
||||
// block stays observable in the agent logs, mirroring the
|
||||
// `failed` branch above.
|
||||
log.warn('acp: turn ended with blocked reason', {
|
||||
reason: event.reason,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
argsByToolCall.clear();
|
||||
startedToolCalls.clear();
|
||||
|
|
@ -1166,7 +1168,7 @@ export class AcpSession {
|
|||
this.currentTurnId = undefined;
|
||||
unsub();
|
||||
}
|
||||
resolve({ stopReason: turnEndReasonToStopReason(event.reason) });
|
||||
resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1514,7 +1516,9 @@ function mapPromptError(err: unknown, sessionId: string): RequestError {
|
|||
* `turn.ended` event hands us a serialized payload (no class identity
|
||||
* to branch on) — we only need the `code` discriminator here.
|
||||
*/
|
||||
function authRequiredFromPayload(payload: KimiErrorPayload | undefined): RequestError | undefined {
|
||||
function authRequiredFromPayload(
|
||||
payload: { readonly code: unknown } | undefined,
|
||||
): RequestError | undefined {
|
||||
if (!payload) return undefined;
|
||||
if (isAuthErrorCode(payload.code)) {
|
||||
return RequestError.authRequired();
|
||||
|
|
|
|||
|
|
@ -260,18 +260,31 @@ describe('AcpServer error mapping', () => {
|
|||
expect(response.stopReason).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('maps the filtered turn-end reason to ACP stopReason refusal', () => {
|
||||
it('maps blocked turn-end reasons to ACP stopReason refusal', () => {
|
||||
// ACP has a native `refusal` stop reason that matches a provider safety
|
||||
// block; mapping filtered to anything else (e.g. end_turn) would let the
|
||||
// client mistake the block for a clean turn.
|
||||
expect(turnEndReasonToStopReason('filtered')).toBe('refusal');
|
||||
// block or prompt-hook block; mapping either to anything else (e.g.
|
||||
// end_turn) would let the client mistake the block for a clean turn.
|
||||
expect(turnEndReasonToStopReason('failed', { code: 'provider.filtered' })).toBe('refusal');
|
||||
expect(turnEndReasonToStopReason('blocked')).toBe('refusal');
|
||||
});
|
||||
|
||||
it('resolves with refusal when turn.ended reason is filtered', async () => {
|
||||
it('resolves with refusal when turn.ended fails with provider.filtered', async () => {
|
||||
const sessionId = 'sess-filtered';
|
||||
const { session, unsubscribeCount } = makeScriptedSession(sessionId, {
|
||||
script: [
|
||||
{ type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'filtered' } as Event,
|
||||
{
|
||||
type: 'turn.ended',
|
||||
sessionId,
|
||||
agentId: 'main',
|
||||
turnId: 1,
|
||||
reason: 'failed',
|
||||
error: {
|
||||
code: 'provider.filtered',
|
||||
message: 'Provider safety policy blocked the response.',
|
||||
name: 'ProviderFilteredError',
|
||||
retryable: false,
|
||||
},
|
||||
} as Event,
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface CodedErrorShape {
|
|||
}
|
||||
|
||||
const PROVIDER_API_ERROR: ErrorCode = 'provider.api_error';
|
||||
const PROVIDER_FILTERED: ErrorCode = 'provider.filtered';
|
||||
const PROVIDER_RATE_LIMIT: ErrorCode = 'provider.rate_limit';
|
||||
const PROVIDER_AUTH_ERROR: ErrorCode = 'provider.auth_error';
|
||||
const PROVIDER_CONNECTION_ERROR: ErrorCode = 'provider.connection_error';
|
||||
|
|
@ -101,7 +102,8 @@ export function toErrorPayload(error: unknown): ErrorPayload {
|
|||
return makeErrorPayload(PROVIDER_CONNECTION_ERROR, error.message, { name: error.name });
|
||||
}
|
||||
if (error instanceof APIEmptyResponseError) {
|
||||
return makeErrorPayload(PROVIDER_API_ERROR, error.message, {
|
||||
const code = error.finishReason === 'filtered' ? PROVIDER_FILTERED : PROVIDER_API_ERROR;
|
||||
return makeErrorPayload(code, error.message, {
|
||||
name: error.name,
|
||||
details: {
|
||||
finishReason: error.finishReason,
|
||||
|
|
|
|||
|
|
@ -375,13 +375,16 @@ async function completeSummary(
|
|||
}
|
||||
|
||||
function classifyTurnResult(result: { reason: string; error?: unknown }): void {
|
||||
if (result.reason === 'filtered') {
|
||||
throw new Error('Subagent turn blocked by provider safety policy');
|
||||
if (result.reason === 'blocked') {
|
||||
throw new Error('Subagent turn blocked by prompt hook');
|
||||
}
|
||||
if (result.reason === 'failed') {
|
||||
const error = result.error;
|
||||
if (isProviderRateLimitError(error)) throw error;
|
||||
const payload = toKimiErrorPayload(error);
|
||||
if (payload.code === ErrorCodes.PROVIDER_FILTERED) {
|
||||
throw new Error('Subagent turn blocked by provider safety policy');
|
||||
}
|
||||
if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
|
||||
throw providerRateLimitErrorFromPayload(payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
private readonly goalDrivenTurns = new Set<number>();
|
||||
private readonly countedGoalTurns = new Set<number>();
|
||||
private readonly goalOutcomeContinuationTurns = new Set<number>();
|
||||
private readonly promptHookBlockedTurns = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private readonly options: GoalServiceOptions = {},
|
||||
|
|
@ -225,13 +224,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
await this.handleTurnEnded(ctx);
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.record.on((event) => {
|
||||
if (event.type === 'hook.result' && event.blocked === true) {
|
||||
this.promptHookBlockedTurns.add(event.turnId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
|
|
@ -454,7 +446,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
const turnId = turn.id;
|
||||
if (this.state?.status === 'active') this.goalDrivenTurns.add(turnId);
|
||||
this.goalOutcomeContinuationTurns.delete(turnId);
|
||||
this.promptHookBlockedTurns.delete(turnId);
|
||||
}
|
||||
|
||||
private async handleBeforeStep(ctx: TurnBeforeStepContext): Promise<void> {
|
||||
|
|
@ -487,8 +478,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
this.countedGoalTurns.delete(turnId);
|
||||
this.goalOutcomeContinuationTurns.delete(turnId);
|
||||
|
||||
const blockedByPromptHook = this.promptHookBlockedTurns.delete(turnId);
|
||||
if (blockedByPromptHook) {
|
||||
if (ctx.result.reason === 'blocked') {
|
||||
await this.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -501,10 +491,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
await this.pauseActiveGoal({ reason: goalFailurePauseReason(ctx.result.error) });
|
||||
return;
|
||||
}
|
||||
if (ctx.result.reason === 'filtered') {
|
||||
await this.pauseActiveGoal({ reason: GOAL_PROVIDER_FILTERED_PAUSE_REASON });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state?.status !== 'active') return;
|
||||
if (this.blockIfBudgetReached(this.state) !== null) return;
|
||||
|
|
@ -797,6 +783,8 @@ function goalFailurePauseReason(error: unknown): string {
|
|||
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, payload.message);
|
||||
case ErrorCodes.PROVIDER_AUTH_ERROR:
|
||||
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, payload.message);
|
||||
case ErrorCodes.PROVIDER_FILTERED:
|
||||
return GOAL_PROVIDER_FILTERED_PAUSE_REASON;
|
||||
case ErrorCodes.PROVIDER_API_ERROR:
|
||||
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, payload.message);
|
||||
case ErrorCodes.MODEL_NOT_CONFIGURED:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
import type { TurnEndReason } from '@moonshot-ai/protocol';
|
||||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
|
||||
import type { Hooks } from '#/hooks';
|
||||
|
||||
export interface TurnResult {
|
||||
readonly reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
|
||||
readonly reason: TurnEndReason;
|
||||
readonly error?: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { toKimiErrorPayload, type KimiErrorPayload } from "#/errors";
|
||||
import { ErrorCodes, KimiError, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
|
||||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
|
||||
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
|
|
@ -208,7 +208,7 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
content: hookResult.message,
|
||||
blocked: true,
|
||||
});
|
||||
return { reason: 'completed' };
|
||||
return { reason: 'blocked' };
|
||||
}
|
||||
|
||||
if (hookResult?.action === 'append') {
|
||||
|
|
@ -286,7 +286,17 @@ function toAgentTurnResult(result: LoopTurnResult, signal: AbortSignal): TurnRes
|
|||
return { reason: 'cancelled', error: signal.reason };
|
||||
}
|
||||
if (result.stopReason === 'filtered') {
|
||||
return { reason: 'filtered' };
|
||||
return {
|
||||
reason: 'failed',
|
||||
error: new KimiError(
|
||||
ErrorCodes.PROVIDER_FILTERED,
|
||||
'Provider safety policy blocked the response.',
|
||||
{
|
||||
name: 'ProviderFilteredError',
|
||||
details: { finishReason: 'filtered' },
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
return { reason: 'completed' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
|
|||
export const ProtocolErrors = {
|
||||
codes: {
|
||||
PROVIDER_API_ERROR: 'provider.api_error',
|
||||
PROVIDER_FILTERED: 'provider.filtered',
|
||||
PROVIDER_RATE_LIMIT: 'provider.rate_limit',
|
||||
PROVIDER_AUTH_ERROR: 'provider.auth_error',
|
||||
PROVIDER_CONNECTION_ERROR: 'provider.connection_error',
|
||||
|
|
@ -23,6 +24,12 @@ export const ProtocolErrors = {
|
|||
public: true,
|
||||
action: 'Retry after the provider rate limit resets.',
|
||||
},
|
||||
'provider.filtered': {
|
||||
title: 'Provider filtered response',
|
||||
retryable: false,
|
||||
public: true,
|
||||
action: 'Revise the prompt or model configuration to avoid provider safety filtering.',
|
||||
},
|
||||
'provider.auth_error': {
|
||||
title: 'Provider authentication failed',
|
||||
retryable: false,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ export class SessionActivity implements ISessionActivity {
|
|||
private hasAbortedTurn(): boolean {
|
||||
for (const handle of this.agents.list()) {
|
||||
const reason = handle.accessor.get(IAgentTurnService).lastEndedReason();
|
||||
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
|
||||
if (
|
||||
reason === 'cancelled' ||
|
||||
reason === 'failed' ||
|
||||
reason === 'blocked'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,7 +487,17 @@ describe('runChildAgent', () => {
|
|||
|
||||
it('classifies a filtered turn as a provider safety policy block', async () => {
|
||||
const parent = fakeScope(CALLER_AGENT_ID);
|
||||
const child = fakeScope('child', { result: Promise.resolve({ reason: 'filtered' }) });
|
||||
const child = fakeScope('child', {
|
||||
result: Promise.resolve({
|
||||
reason: 'failed',
|
||||
error: {
|
||||
code: 'provider.filtered',
|
||||
message: 'Provider safety policy blocked the response.',
|
||||
name: 'ProviderFilteredError',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const agents = makeAgents(parent, { child });
|
||||
|
||||
const handle = await spawnChildAgent({
|
||||
|
|
|
|||
|
|
@ -700,14 +700,7 @@ describe('AgentGoalService core workflow hooks', () => {
|
|||
|
||||
const turn = makeTurn(5);
|
||||
await turnService.hooks.onLaunched.run({ turn });
|
||||
eventSink.emit({
|
||||
type: 'hook.result',
|
||||
turnId: turn.id,
|
||||
hookEvent: 'UserPromptSubmit',
|
||||
content: 'blocked',
|
||||
blocked: true,
|
||||
});
|
||||
await endTurn(turnService, turn);
|
||||
await endTurn(turnService, turn, { reason: 'blocked' });
|
||||
|
||||
expect(goals.getGoal().goal).toMatchObject({
|
||||
status: 'blocked',
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ describe('Agent loop', () => {
|
|||
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [], "providerMessageId": "mock-1" } ], "time": "<time>" }
|
||||
[emit] agent.status.updated { "contextTokens": 0 }
|
||||
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "filtered" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "filtered" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered", "turnId": 0 }, "retryable": false } }
|
||||
[emit] error { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered", "turnId": 0 }, "retryable": false }
|
||||
`);
|
||||
|
||||
const rpcStepEnd = ctx.allEvents.find(
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ describe('Agent turn flow', () => {
|
|||
args: expect.objectContaining({
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'provider.api_error',
|
||||
code: 'provider.filtered',
|
||||
name: 'APIEmptyResponseError',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
|
|
@ -532,7 +532,7 @@ describe('Agent turn flow', () => {
|
|||
type: '[rpc]',
|
||||
event: 'error',
|
||||
args: expect.objectContaining({
|
||||
code: 'provider.api_error',
|
||||
code: 'provider.filtered',
|
||||
name: 'APIEmptyResponseError',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
|
|
@ -544,7 +544,7 @@ describe('Agent turn flow', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('ends the turn with reason filtered when the provider filters a non-empty response', async () => {
|
||||
it('ends the turn with a provider.filtered error when the provider filters a non-empty response', async () => {
|
||||
const generate: GenerateFn = async () => ({
|
||||
id: null,
|
||||
message: {
|
||||
|
|
@ -575,7 +575,14 @@ describe('Agent turn flow', () => {
|
|||
type: '[rpc]',
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({
|
||||
reason: 'filtered',
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'provider.filtered',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
turnId: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -707,6 +714,12 @@ describe('Agent turn flow', () => {
|
|||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({ reason: 'blocked' }),
|
||||
}),
|
||||
);
|
||||
expect(ctx.contextData().history).toMatchObject([
|
||||
{
|
||||
role: 'user',
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ export class TurnFlow {
|
|||
goalBecameActive &&
|
||||
end.event.reason !== 'cancelled' &&
|
||||
end.event.reason !== 'failed' &&
|
||||
end.event.reason !== 'filtered'
|
||||
end.event.reason !== 'blocked'
|
||||
) {
|
||||
return await this.driveGoal(
|
||||
this.allocateTurnId(),
|
||||
|
|
@ -387,11 +387,7 @@ export class TurnFlow {
|
|||
await this.agent.goal.pauseActiveGoal({ reason: goalFailurePauseReason(end.event.error) });
|
||||
return end;
|
||||
}
|
||||
if (end.event.reason === 'filtered') {
|
||||
await this.agent.goal.pauseActiveGoal({ reason: GOAL_PROVIDER_FILTERED_PAUSE_REASON });
|
||||
return end;
|
||||
}
|
||||
if (end.blockedByUserPromptHook === true) {
|
||||
if (end.event.reason === 'blocked' || end.blockedByUserPromptHook === true) {
|
||||
await this.agent.goal.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
|
||||
return end;
|
||||
}
|
||||
|
|
@ -476,14 +472,25 @@ export class TurnFlow {
|
|||
} else {
|
||||
const stopReason = await this.runStepLoop(turnId, signal);
|
||||
completedStopReason = stopReason;
|
||||
const reason: TurnEndReason =
|
||||
stopReason === 'aborted' ? 'cancelled' : stopReason === 'filtered' ? 'filtered' : 'completed';
|
||||
ended = {
|
||||
type: 'turn.ended',
|
||||
turnId,
|
||||
reason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (stopReason === 'filtered') {
|
||||
const summary = providerFilteredPayload(turnId);
|
||||
ended = {
|
||||
type: 'turn.ended',
|
||||
turnId,
|
||||
reason: 'failed',
|
||||
error: summary,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
errorEvent = { type: 'error', ...summary };
|
||||
} else {
|
||||
const reason: TurnEndReason = stopReason === 'aborted' ? 'cancelled' : 'completed';
|
||||
ended = {
|
||||
type: 'turn.ended',
|
||||
turnId,
|
||||
reason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
|
|
@ -606,7 +613,7 @@ export class TurnFlow {
|
|||
// The terminal turn.ended is emitted by runOneTurn (synchronously with the
|
||||
// activeTurn clear), not here, so the session is idle the moment it fires.
|
||||
return {
|
||||
event: { type: 'turn.ended', turnId, reason: 'completed', durationMs: Date.now() - startedAt },
|
||||
event: { type: 'turn.ended', turnId, reason: 'blocked', durationMs: Date.now() - startedAt },
|
||||
blocked: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -1091,7 +1098,17 @@ function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
|
|||
return { ...payload, details };
|
||||
}
|
||||
|
||||
function goalFailurePauseReason(error: KimiErrorPayload | undefined): string {
|
||||
function providerFilteredPayload(turnId: number): KimiErrorPayload {
|
||||
return {
|
||||
code: ErrorCodes.PROVIDER_FILTERED,
|
||||
message: 'Provider safety policy blocked the response.',
|
||||
name: 'ProviderFilteredError',
|
||||
details: { finishReason: 'filtered', turnId },
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function goalFailurePauseReason(error: TurnEndedEvent['error']): string {
|
||||
if (error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) return GOAL_RATE_LIMIT_PAUSE_REASON;
|
||||
if (error?.code === ErrorCodes.PROVIDER_CONNECTION_ERROR) {
|
||||
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, error.message);
|
||||
|
|
@ -1099,6 +1116,9 @@ function goalFailurePauseReason(error: KimiErrorPayload | undefined): string {
|
|||
if (error?.code === ErrorCodes.PROVIDER_AUTH_ERROR) {
|
||||
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, error.message);
|
||||
}
|
||||
if (error?.code === ErrorCodes.PROVIDER_FILTERED) {
|
||||
return GOAL_PROVIDER_FILTERED_PAUSE_REASON;
|
||||
}
|
||||
if (error?.code === ErrorCodes.PROVIDER_API_ERROR) {
|
||||
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, error.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const ErrorCodes = {
|
|||
CONTEXT_OVERFLOW: 'context.overflow',
|
||||
LOOP_MAX_STEPS_EXCEEDED: 'loop.max_steps_exceeded',
|
||||
PROVIDER_API_ERROR: 'provider.api_error',
|
||||
PROVIDER_FILTERED: 'provider.filtered',
|
||||
PROVIDER_RATE_LIMIT: 'provider.rate_limit',
|
||||
PROVIDER_AUTH_ERROR: 'provider.auth_error',
|
||||
PROVIDER_CONNECTION_ERROR: 'provider.connection_error',
|
||||
|
|
@ -309,6 +310,12 @@ export const KIMI_ERROR_INFO = {
|
|||
public: true,
|
||||
action: 'Inspect details.statusCode / details.requestId; check provider status.',
|
||||
},
|
||||
'provider.filtered': {
|
||||
title: 'Provider filtered response',
|
||||
retryable: false,
|
||||
public: true,
|
||||
action: 'Revise the prompt or model configuration to avoid provider safety filtering.',
|
||||
},
|
||||
'provider.rate_limit': {
|
||||
title: 'Provider rate limit',
|
||||
retryable: true,
|
||||
|
|
|
|||
|
|
@ -104,15 +104,19 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload {
|
|||
}
|
||||
|
||||
if (error instanceof APIEmptyResponseError) {
|
||||
const code =
|
||||
error.finishReason === 'filtered'
|
||||
? ErrorCodes.PROVIDER_FILTERED
|
||||
: ErrorCodes.PROVIDER_API_ERROR;
|
||||
return {
|
||||
code: ErrorCodes.PROVIDER_API_ERROR,
|
||||
code,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
details: {
|
||||
finishReason: error.finishReason,
|
||||
rawFinishReason: error.rawFinishReason,
|
||||
},
|
||||
retryable: KIMI_ERROR_INFO[ErrorCodes.PROVIDER_API_ERROR].retryable,
|
||||
retryable: KIMI_ERROR_INFO[code].retryable,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
* later abort can pass the correct numeric `turnId` to
|
||||
* `core.rpc.cancel({turnId})`).
|
||||
* - capture `turn.ended` for the prompt's top-level turn → SYNTHESIZE a
|
||||
* `prompt.completed` (reason='completed' or 'failed') or
|
||||
* `prompt.completed` (reason='completed', 'failed', or 'blocked') or
|
||||
* `prompt.aborted` (reason='cancelled') event. The event service then
|
||||
* broadcasts these. agent-core's event union has no prompt-level
|
||||
* types.
|
||||
|
|
@ -215,7 +215,7 @@ export interface IPromptService {
|
|||
/**
|
||||
* VSCode-style accessor for `prompt.completed` synthetic events. The
|
||||
* listener fires synchronously when a top-level `turn.ended`
|
||||
* (reason='completed'|'failed') is synthesised into a prompt-lifecycle
|
||||
* (reason='completed'|'failed'|'blocked') is synthesised into a prompt-lifecycle
|
||||
* event, BEFORE `bus.publish(synth)`.
|
||||
*
|
||||
* Returns an `IDisposable`. Owners stash it via
|
||||
|
|
@ -309,7 +309,7 @@ export interface SyntheticPromptCompletedEvent {
|
|||
readonly sessionId: string;
|
||||
readonly promptId: string;
|
||||
readonly finishedAt: string;
|
||||
readonly reason: 'completed' | 'failed';
|
||||
readonly reason: 'completed' | 'failed' | 'blocked';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ interface PromptState {
|
|||
body: PromptSubmission;
|
||||
createdAt: string;
|
||||
turnId: number | null;
|
||||
/** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'|'filtered'). */
|
||||
/** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'|'blocked'). */
|
||||
completed: boolean;
|
||||
/** Set on `turn.ended` with reason='cancelled' or after a successful abort RPC. */
|
||||
aborted: boolean;
|
||||
|
|
@ -205,7 +205,7 @@ function isTurnStarted(e: Event): e is Event & { type: 'turn.started'; turnId: n
|
|||
function isTurnEnded(e: Event): e is Event & {
|
||||
type: 'turn.ended';
|
||||
turnId: number;
|
||||
reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
|
||||
reason: 'completed' | 'cancelled' | 'failed' | 'blocked';
|
||||
} {
|
||||
return (e as { type?: string }).type === 'turn.ended';
|
||||
}
|
||||
|
|
@ -853,7 +853,7 @@ export class PromptService
|
|||
sessionId: sid,
|
||||
promptId: state.promptId,
|
||||
finishedAt: new Date().toISOString(),
|
||||
reason: reason === 'failed' || reason === 'filtered' ? 'failed' : 'completed',
|
||||
reason: reason === 'failed' || reason === 'blocked' ? reason : 'completed',
|
||||
};
|
||||
this._active.delete(key);
|
||||
// Fire typed listeners BEFORE publishing the synth event.
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
case 'turn.ended': {
|
||||
this._activeTurns.delete(sessionId);
|
||||
const reason = (event as { reason?: string }).reason;
|
||||
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
|
||||
if (reason === 'cancelled' || reason === 'failed' || reason === 'blocked') {
|
||||
this._abortedTurns.add(sessionId);
|
||||
} else {
|
||||
this._abortedTurns.delete(sessionId);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
|
||||
import type { Agent } from '../agent';
|
||||
import type { PromptOrigin } from '../agent/context';
|
||||
import { ErrorCodes, type KimiErrorPayload } from '../errors';
|
||||
import { ErrorCodes } from '../errors';
|
||||
import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all';
|
||||
import { InMemoryAgentRecordPersistence } from '../agent/records';
|
||||
import { isAbortError } from '../loop/errors';
|
||||
|
|
@ -469,7 +469,7 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom
|
|||
const completion = await child.turn.waitForCurrentTurn(signal);
|
||||
const turnEnded = completion.event;
|
||||
if (turnEnded.reason !== 'completed') {
|
||||
if (turnEnded.reason === 'filtered') {
|
||||
if (turnEnded.error?.code === ErrorCodes.PROVIDER_FILTERED) {
|
||||
throw new Error('Subagent turn blocked by provider safety policy');
|
||||
}
|
||||
if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
|
||||
|
|
@ -486,7 +486,10 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom
|
|||
}
|
||||
}
|
||||
|
||||
function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
|
||||
function providerRateLimitErrorFromPayload(error: {
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}): APIProviderRateLimitError {
|
||||
const requestId =
|
||||
typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
|
||||
return new APIProviderRateLimitError(error.message, requestId);
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ describe('Agent turn flow', () => {
|
|||
args: expect.objectContaining({
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'provider.api_error',
|
||||
code: 'provider.filtered',
|
||||
name: 'APIEmptyResponseError',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
|
|
@ -441,7 +441,7 @@ describe('Agent turn flow', () => {
|
|||
type: '[rpc]',
|
||||
event: 'error',
|
||||
args: expect.objectContaining({
|
||||
code: 'provider.api_error',
|
||||
code: 'provider.filtered',
|
||||
name: 'APIEmptyResponseError',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
|
|
@ -453,7 +453,7 @@ describe('Agent turn flow', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('ends the turn with reason filtered when the provider filters a non-empty response', async () => {
|
||||
it('ends the turn with a provider.filtered error when the provider filters a non-empty response', async () => {
|
||||
const generate: GenerateFn = async () => ({
|
||||
id: null,
|
||||
message: {
|
||||
|
|
@ -484,7 +484,14 @@ describe('Agent turn flow', () => {
|
|||
type: '[rpc]',
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({
|
||||
reason: 'filtered',
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'provider.filtered',
|
||||
details: expect.objectContaining({
|
||||
finishReason: 'filtered',
|
||||
turnId: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -670,6 +677,12 @@ describe('Agent turn flow', () => {
|
|||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({ reason: 'blocked' }),
|
||||
}),
|
||||
);
|
||||
expect(ctx.agent.context.data().history).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
|
|
|
|||
|
|
@ -511,7 +511,11 @@ describe('goal session end-to-end', () => {
|
|||
expect(goal?.status).toBe('paused');
|
||||
expect(goal?.terminalReason).toBe('Paused after provider safety policy block');
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: 'turn.ended', reason: 'filtered' }),
|
||||
expect.objectContaining({
|
||||
type: 'turn.ended',
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({ code: 'provider.filtered' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -701,6 +701,38 @@ describe('PromptService lifecycle synthesis (via IEventService.onDidPublish)', (
|
|||
expect(impl._activeForTest(SID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves blocked reason when synthesizing prompt.completed', async () => {
|
||||
const { bridge } = makeBridge();
|
||||
const { bus, events, triggerSubscribers } = makeBus();
|
||||
const impl = newSvc(bridge, bus);
|
||||
const submit = await impl.submit(SID, mkBody());
|
||||
triggerSubscribers({
|
||||
type: 'turn.started',
|
||||
turnId: 7,
|
||||
origin: { kind: 'user' },
|
||||
sessionId: SID,
|
||||
agentId: 'main',
|
||||
} as unknown as Event);
|
||||
events.length = 0;
|
||||
triggerSubscribers({
|
||||
type: 'turn.ended',
|
||||
turnId: 7,
|
||||
reason: 'blocked',
|
||||
sessionId: SID,
|
||||
agentId: 'main',
|
||||
} as unknown as Event);
|
||||
expect(events).toHaveLength(1);
|
||||
const synth = events[0] as unknown as {
|
||||
type: string;
|
||||
promptId: string;
|
||||
reason: string;
|
||||
};
|
||||
expect(synth.type).toBe('prompt.completed');
|
||||
expect(synth.promptId).toBe(submit.prompt_id);
|
||||
expect(synth.reason).toBe('blocked');
|
||||
expect(impl._activeForTest(SID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fires onDidComplete listener before bus.publish', async () => {
|
||||
const { bridge } = makeBridge();
|
||||
const { bus, events, triggerSubscribers } = makeBus();
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,13 @@ describe('SessionService status lifecycle', () => {
|
|||
expect((await svc.get(session.id)).status).toBe('aborted');
|
||||
});
|
||||
|
||||
it('turn.ended with blocked moves status to aborted', async () => {
|
||||
const session = await svc.create({ metadata: { cwd: '/tmp/blocked' } });
|
||||
eventBus.eventService.publish({ type: 'turn.started', sessionId: session.id } as unknown as Event);
|
||||
eventBus.eventService.publish({ type: 'turn.ended', sessionId: session.id, reason: 'blocked' } as unknown as Event);
|
||||
expect((await svc.get(session.id)).status).toBe('aborted');
|
||||
});
|
||||
|
||||
it('prompt.submitted moves status to running when a current prompt exists', async () => {
|
||||
const session = await svc.create({ metadata: { cwd: '/tmp/prompt' } });
|
||||
promptStub.activePromptIds.set(session.id, 'p1');
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ export type KimiErrorCode =
|
|||
| 'context.overflow'
|
||||
| 'loop.max_steps_exceeded'
|
||||
| 'provider.api_error'
|
||||
| 'provider.filtered'
|
||||
| 'provider.rate_limit'
|
||||
| 'provider.auth_error'
|
||||
| 'provider.connection_error'
|
||||
|
|
@ -339,7 +340,7 @@ export interface McpOAuthAuthorizationUrlUpdateData {
|
|||
readonly authorizationUrl: string;
|
||||
}
|
||||
|
||||
export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'filtered';
|
||||
export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked';
|
||||
|
||||
export interface AgentStatusUpdatedEvent {
|
||||
readonly type: 'agent.status.updated';
|
||||
|
|
@ -960,6 +961,7 @@ export const kimiErrorCodeSchema = z.enum([
|
|||
'context.overflow',
|
||||
'loop.max_steps_exceeded',
|
||||
'provider.api_error',
|
||||
'provider.filtered',
|
||||
'provider.rate_limit',
|
||||
'provider.auth_error',
|
||||
'provider.connection_error',
|
||||
|
|
@ -1058,7 +1060,7 @@ export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({
|
|||
authorizationUrl: z.string(),
|
||||
}) satisfies z.ZodType<McpOAuthAuthorizationUrlUpdateData>;
|
||||
|
||||
export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'filtered']) satisfies z.ZodType<TurnEndReason>;
|
||||
export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType<TurnEndReason>;
|
||||
|
||||
export const agentStatusUpdatedEventSchema = z.object({
|
||||
type: z.literal('agent.status.updated'),
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
this.eventService.onDidPublish((event) => {
|
||||
if ((event as { type?: string }).type !== 'turn.ended') return;
|
||||
const reason = (event as { reason?: string }).reason;
|
||||
if (reason !== 'cancelled' && reason !== 'failed' && reason !== 'filtered') return;
|
||||
if (reason !== 'cancelled' && reason !== 'failed' && reason !== 'blocked') return;
|
||||
const sessionId = (event as { sessionId?: string }).sessionId;
|
||||
if (sessionId === undefined || sessionId === '') return;
|
||||
this.dismissForSession(sessionId);
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export class SnapshotService extends Disposable implements ISnapshotService {
|
|||
case 'turn.ended': {
|
||||
this._activeTurns.delete(sessionId);
|
||||
const reason = (event as { reason?: string }).reason;
|
||||
if (reason === 'cancelled' || reason === 'failed') {
|
||||
if (reason === 'cancelled' || reason === 'failed' || reason === 'blocked') {
|
||||
this._abortedTurns.add(sessionId);
|
||||
} else {
|
||||
this._abortedTurns.delete(sessionId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue