Skip to content
Draft
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
19 changes: 12 additions & 7 deletions types/channels-chat/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +20,8 @@ import type {
ToolCallRiskAssessment,
ToolInput,
Turn,
TurnError,
TurnMessage,
} from './state.js';
import {
ToolCallConfirmationReason,
Expand Down Expand Up @@ -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
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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.
*
Expand Down
25 changes: 24 additions & 1 deletion types/channels-chat/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import type {
PendingMessage,
ConfirmationOption,
ToolCallContributor,
ContinuationMessage,
TurnError,
TurnMessage,
} from './state.js';
import {
TurnState,
Expand All @@ -25,13 +28,18 @@ import {
ToolCallContributorKind,
ResponsePartKind,
PendingMessageKind,
MessageKind,
} from './state.js';
import { SessionStatus } from '../channels-session/state.js';
import type { ChatAction } from '../action-origin.generated.js';
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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
94 changes: 82 additions & 12 deletions types/channels-chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand All @@ -566,7 +580,7 @@ export interface Turn {
/** How the turn ended */
state: TurnState;
/** Error details if state is `'error'` */
error?: ErrorInfo;
error?: TurnError;
}

/**
Expand All @@ -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.
*
Expand All @@ -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
*/
Expand All @@ -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
Expand All @@ -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[];
/**
Expand Down Expand Up @@ -671,6 +713,34 @@ export interface Message {
_meta?: Record<string, unknown>;
}

/**
* 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<string, unknown>;
}

/**
* A message that starts a turn.
*
* @category Turn Types
*/
export type TurnMessage = Message | ContinuationMessage;

/**
* Common fields shared by all {@link MessageAttachment} variants.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading