diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index caecaeb11..91ebf6bd3 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -5,7 +5,7 @@ */ import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; +import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, @@ -20,6 +20,8 @@ import type { ToolCallRiskAssessment, ToolInput, Turn, + TurnError, + TurnMessage, } from './state.js'; import { ToolCallConfirmationReason, @@ -56,9 +58,12 @@ interface ToolCallActionBase { // ─── Chat Actions ─────────────────────────────────────────────────────────── /** - * A new message has been sent to the agent, and a new turn starts. + * A new turn starts from a user message or continuation. * - * A client is only allowed to send {@link MessageKind.User} messages. + * A client may start a turn with a {@link MessageKind.User} message, or + * continue the latest failed turn with a {@link MessageKind.Continuation} + * message. A continuation starts a new turn and leaves the failed turn + * unchanged in history. * * @category Chat Actions * @version 1 @@ -70,8 +75,8 @@ export interface ChatTurnStartedAction { turnId: string; /** ISO 8601 timestamp when this turn started. */ startedAt: string; - /** The new message */ - message: Message; + /** The message that initiates the turn. */ + message: TurnMessage; /** If this turn was auto-started from a queued message, the ID of that message */ queuedMessageId?: string; /** @@ -488,8 +493,8 @@ export interface ChatErrorAction { * data. */ duration: number; - /** Error details */ - error: ErrorInfo; + /** Error details and optional continuation eligibility. */ + error: TurnError; /** * Additional provider-specific metadata for this action. * diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index b170cefb2..d37c47e54 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -16,6 +16,9 @@ import type { PendingMessage, ConfirmationOption, ToolCallContributor, + ContinuationMessage, + TurnError, + TurnMessage, } from './state.js'; import { TurnState, @@ -25,6 +28,7 @@ import { ToolCallContributorKind, ResponsePartKind, PendingMessageKind, + MessageKind, } from './state.js'; import { SessionStatus } from '../channels-session/state.js'; import type { ChatAction } from '../action-origin.generated.js'; @@ -32,6 +36,10 @@ import { softAssertNever } from '../common/reducer-helpers.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── +function isContinuationMessage(message: TurnMessage): message is ContinuationMessage { + return message.origin.kind === MessageKind.Continuation; +} + /** Extracts the common base fields shared by all tool call lifecycle states. */ function tcBase(tc: ToolCallState) { return { @@ -170,7 +178,7 @@ function endTurn( turnState: TurnState, duration: number, terminalStatus?: SessionStatus.Error, - error?: { errorType: string; message: string; stack?: string }, + error?: TurnError, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; @@ -343,6 +351,21 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st // ── Turn Lifecycle ──────────────────────────────────────────────────── case ActionType.ChatTurnStarted: { + if (isContinuationMessage(action.message)) { + const previousTurn = state.turns[state.turns.length - 1]; + if ( + state.activeTurn + || !previousTurn + || previousTurn.state !== TurnState.Error + || previousTurn.error?.continuation !== true + || state.turns.some(turn => turn.id === action.turnId) + || action.message.text.length > 0 + || action.message.attachments !== undefined + || action.queuedMessageId !== undefined + ) { + return state; + } + } let next: ChatState = { ...state, activeTurn: { diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 8e107a23b..6a6b9ba4e 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -522,6 +522,20 @@ export const enum TurnState { Error = 'error', } +/** + * Error details for a failed turn. + * + * When {@link continuation} is present, a client may start a new adjacent turn + * with a {@link ContinuationMessage}. The continuation proceeds from the + * failed turn without adding user input or changing the failed turn. + * + * @category Turn Types + */ +export interface TurnError extends ErrorInfo { + /** Whether the latest failed turn may be continued. */ + continuation?: true; +} + /** * Discriminant for {@link MessageAttachment} variants. * @@ -553,7 +567,7 @@ export interface Turn { /** Turn duration in milliseconds. */ duration?: number; /** The message that initiated the turn */ - message: Message; + message: TurnMessage; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * @@ -566,7 +580,7 @@ export interface Turn { /** How the turn ended */ state: TurnState; /** Error details if state is `'error'` */ - error?: ErrorInfo; + error?: TurnError; } /** @@ -580,7 +594,7 @@ export interface ActiveTurn { /** ISO 8601 timestamp when this turn started. */ startedAt: string; /** The message that initiated the turn */ - message: Message; + message: TurnMessage; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * @@ -592,7 +606,8 @@ export interface ActiveTurn { } /** - * Discriminant for {@link MessageOrigin} — identifies who produced a message. + * Discriminant for {@link MessageOrigin} — identifies a message's origin or + * continuation provenance. * * @category Turn Types */ @@ -611,23 +626,50 @@ export enum MessageKind { Tool = 'tool', /** A system-generated notification rather than a direct user message. */ SystemNotification = 'systemNotification', + /** + * Starts a new turn that continues a preceding failed turn without adding + * another user message. + */ + Continuation = 'continuation', } /** - * Identifies the origin of a {@link Message} — who produced it. For the message - * that initiates a turn ({@link Turn.message}), this is also the origin of the - * turn; for steering or queued messages it is just the origin of that message. + * Identifies the actor that produced an ordinary {@link Message}. * * @category Turn Types */ -export interface MessageOrigin { +export interface ActorMessageOrigin { /** The kind of actor that produced the message. */ - kind: MessageKind; + kind: MessageKind.User + | MessageKind.Agent + | MessageKind.Tool + | MessageKind.SystemNotification; +} + +/** + * Identifies a message that starts a new turn as a continuation of the + * immediately preceding failed turn. + * + * Continuation messages carry no new user input: their text is empty and they + * have no attachments. The preceding turn remains unchanged in history. + * + * @category Turn Types + */ +export interface ContinuationMessageOrigin { + /** Discriminant */ + kind: MessageKind.Continuation; } /** - * A message that initiates or steers a turn. Messages can originate from the - * user, the agent, a tool, or be system-generated (see {@link MessageOrigin}). + * Identifies the origin or provenance of a {@link TurnMessage}. + * + * @category Turn Types + */ +export type MessageOrigin = ActorMessageOrigin | ContinuationMessageOrigin; + +/** + * An ordinary message that initiates or steers a turn. Messages can originate + * from the user, the agent, a tool, or be system-generated. * * Attachments MAY be referenced inside {@link Message.text} via their * {@link MessageAttachmentBase.range} field. Attachments without a range are @@ -640,7 +682,7 @@ export interface Message { /** Message text */ text: string; /** The origin of the message */ - origin: MessageOrigin; + origin: ActorMessageOrigin; /** File/selection attachments */ attachments?: MessageAttachment[]; /** @@ -671,6 +713,34 @@ export interface Message { _meta?: Record; } +/** + * A message that starts a new turn to continue a preceding failed turn without + * adding user input. + * + * @category Turn Types + */ +export interface ContinuationMessage { + /** Continuations add no message text. */ + text: ''; + /** Continuation provenance. */ + origin: ContinuationMessageOrigin; + /** Continuations cannot carry attachments. */ + attachments?: never; + /** Optional model override for the continuation attempt. */ + model?: ModelSelection; + /** Optional custom-agent override for the continuation attempt. */ + agent?: AgentSelection; + /** Additional provider-specific metadata for this continuation. */ + _meta?: Record; +} + +/** + * A message that starts a turn. + * + * @category Turn Types + */ +export type TurnMessage = Message | ContinuationMessage; + /** * Common fields shared by all {@link MessageAttachment} variants. * diff --git a/types/test-cases/reducers/263-chat-continuation-starts-new-turn-after-error.json b/types/test-cases/reducers/263-chat-continuation-starts-new-turn-after-error.json new file mode 100644 index 000000000..e9fd0d4fe --- /dev/null +++ b/types/test-cases/reducers/263-chat-continuation-starts-new-turn-after-error.json @@ -0,0 +1,95 @@ +{ + "description": "chat/turnStarted starts a new continuation turn after the latest failed turn without rewriting history", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + } + ], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "duration": 1000, + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-1", + "content": "Partial response" + } + ], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "activeTurn": { + "id": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/test-cases/reducers/264-chat-continuation-completes-as-adjacent-turn.json b/types/test-cases/reducers/264-chat-continuation-completes-as-adjacent-turn.json new file mode 100644 index 000000000..ef7437455 --- /dev/null +++ b/types/test-cases/reducers/264-chat-continuation-completes-as-adjacent-turn.json @@ -0,0 +1,103 @@ +{ + "description": "a continuation completes as a distinct turn adjacent to the failed source turn", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + }, + { + "type": "chat/responsePart", + "turnId": "turn-2", + "part": { + "kind": "markdown", + "id": "markdown-2", + "content": "Recovered" + } + }, + { + "type": "chat/turnComplete", + "turnId": "turn-2", + "duration": 2000 + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + }, + { + "id": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "duration": 2000, + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + }, + "responseParts": [ + { + "kind": "markdown", + "id": "markdown-2", + "content": "Recovered" + } + ], + "usage": null, + "state": "complete", + "error": null + } + ], + "activeTurn": null, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:09.999Z" + } +} diff --git a/types/test-cases/reducers/265-chat-continuation-requires-latest-failed-turn.json b/types/test-cases/reducers/265-chat-continuation-requires-latest-failed-turn.json new file mode 100644 index 000000000..3890566c5 --- /dev/null +++ b/types/test-cases/reducers/265-chat-continuation-requires-latest-failed-turn.json @@ -0,0 +1,104 @@ +{ + "description": "continuation starts are ignored unless they reference the latest failed turn", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "First failure", + "continuation": true + } + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete", + "error": null + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-3", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + }, + { + "type": "chat/turnStarted", + "turnId": "turn-4", + "startedAt": "1970-01-01T00:00:04.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "First", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "First failure", + "continuation": true + } + }, + { + "id": "turn-2", + "message": { + "text": "Second", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "complete", + "error": null + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 1, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/266-chat-continuation-rejects-new-input.json b/types/test-cases/reducers/266-chat-continuation-rejects-new-input.json new file mode 100644 index 000000000..f20376a99 --- /dev/null +++ b/types/test-cases/reducers/266-chat-continuation-rejects-new-input.json @@ -0,0 +1,102 @@ +{ + "description": "continuation messages cannot smuggle new user input or queued-message identity", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "Try again", + "origin": { + "kind": "continuation" + } + } + }, + { + "type": "chat/turnStarted", + "turnId": "turn-3", + "startedAt": "1970-01-01T00:00:04.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + }, + "attachments": [] + } + }, + { + "type": "chat/turnStarted", + "turnId": "turn-4", + "startedAt": "1970-01-01T00:00:05.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + }, + "queuedMessageId": "queued-1" + }, + { + "type": "chat/turnStarted", + "turnId": "turn-1", + "startedAt": "1970-01-01T00:00:06.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/267-chat-continuation-noop-while-turn-active.json b/types/test-cases/reducers/267-chat-continuation-noop-while-turn-active.json new file mode 100644 index 000000000..a3f950b2e --- /dev/null +++ b/types/test-cases/reducers/267-chat-continuation-noop-while-turn-active.json @@ -0,0 +1,91 @@ +{ + "description": "a continuation cannot start while another turn is active", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "activeTurn": { + "id": "turn-2", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Other work", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-3", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "runtime", + "message": "Something broke", + "continuation": true + } + } + ], + "activeTurn": { + "id": "turn-2", + "startedAt": "1970-01-01T00:00:02.000Z", + "message": { + "text": "Other work", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 8, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/reducers/268-chat-continuation-requires-eligibility.json b/types/test-cases/reducers/268-chat-continuation-requires-eligibility.json new file mode 100644 index 000000000..1b95cc3b7 --- /dev/null +++ b/types/test-cases/reducers/268-chat-continuation-requires-eligibility.json @@ -0,0 +1,65 @@ +{ + "description": "a failed turn without continuation eligibility cannot be continued", + "reducer": "chat", + "initial": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "invalidRequest", + "message": "The request cannot be retried" + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/turnStarted", + "turnId": "turn-2", + "startedAt": "1970-01-01T00:00:03.000Z", + "message": { + "text": "", + "origin": { + "kind": "continuation" + } + } + } + ], + "expected": { + "turns": [ + { + "id": "turn-1", + "message": { + "text": "Fix the bug", + "origin": { + "kind": "user" + } + }, + "responseParts": [], + "usage": null, + "state": "error", + "error": { + "errorType": "invalidRequest", + "message": "The request cannot be retried" + } + } + ], + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 2, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +}