/** * companion-chat-turn-control.ts * * Turn-lifecycle control for companion chat: the per-turn abort scope, the * cancel finalization path (`companion.chat.turns.cancel`), and the pending * turn queue that backs queue-when-busy sends and the steer verb * (`companion.chat.messages.steer`). * * Split out of companion-chat-manager.ts (which is under the repo's line * cap): everything here is policy over structural dependencies the manager * injects, no manager privates are imported, matching the pattern the other * companion helper modules use. */ import type { CompanionChatMessageAttachmentInput } from './companion-chat-types.js'; import type { ConversationManager } from '../core/conversation.js'; import type { ProviderMessage } from '../providers/interface.js'; import type { ToolDefinition } from '../types/tools.js'; import type { CancelCompanionChatTurnInput, CancelCompanionChatTurnOutput, CompanionChatMessage, CompanionChatTurnEvent } from './companion-chat-types.js'; export type CompanionProviderMessage = ProviderMessage; export interface CompanionProviderChunk { readonly type: 'text_delta' | 'tool_call' | 'tool_result' | 'done' | 'error'; readonly delta?: string | undefined; readonly toolCallId?: string | undefined; readonly toolName?: string | undefined; readonly toolInput?: unknown | undefined; readonly result?: unknown | undefined; readonly isError?: boolean | undefined; readonly error?: string | undefined; } export interface CompanionLLMProvider { /** Stream a single-turn conversation. Yields chunks. */ chatStream(messages: CompanionProviderMessage[], options: { readonly systemPrompt?: string | null | undefined; readonly model?: string | null | undefined; readonly provider?: string | null | undefined; readonly tools?: readonly ToolDefinition[] | undefined; readonly abortSignal?: AbortSignal | undefined; }): AsyncIterable; } /** What a settled (finished or finalized-cancelled) turn reports back. */ export interface TurnSettleResult { readonly partialPersisted: boolean; readonly assistantMessageId?: string | undefined; } /** * The in-flight turn a `companion.chat.turns.cancel` can target. One slot per * session: the most recently started, not-yet-finished turn owns it (the * optional turnId guard on cancel exists exactly for the race where a stale * stop click lands after a newer turn has taken the slot). */ export interface ActiveCompanionTurn { readonly turnId: string; /** Per-turn controller, chained UNDER the session controller, never the reverse. */ readonly controller: AbortController; /** Set by cancel before aborting; distinguishes a user stop from a session close. */ cancelRequested: boolean; /** Resolves when the turn's cancel-finalization (or any other exit) has run. */ readonly settled: Promise; } export interface TurnAbortScope { readonly activeTurn: ActiveCompanionTurn; readonly abortSignal: AbortSignal; /** Settle the turn (idempotent, the first call wins). */ readonly settle: (result: TurnSettleResult) => void; /** Detach the session-abort chain listener. Call on every exit path. */ readonly detach: () => void; } /** * Create the per-turn abort scope, chained UNDER the session-level controller. * A user cancel aborts ONLY this turn; the session controller (close/delete/ * shutdown) still aborts everything. The session controller must never be * aborted for a single-turn stop, its signal stays aborted forever and would * poison every later turn in the session. */ export declare function createTurnAbortScope(turnId: string, sessionSignal: AbortSignal): TurnAbortScope; /** * Appended (model-facing only) to an interrupted partial when it is committed * to the conversation history, so the model can reason about the true chain * of events on later turns, a user's follow-up often refers to what it was * watching at the moment it hit stop. The transcript copy stays clean; the UI * carries the marker as the deliveryState badge instead. */ export declare const TURN_INTERRUPTION_NOTE = "\n\n[Interrupted: the user stopped this response here, before it was complete.]"; /** Structural dependencies finalizeCancelledTurn needs from the manager. */ export interface CancelFinalizeContext { readonly sessionId: string; readonly turnId: string; readonly assistantMessageId: string; /** The user message that started the turn (the partial's inReplyTo link). */ readonly userMessageId: string; /** Read at finalize time, the streamed partial accumulates in a mutable local. */ readonly getAssistantContent: () => string; /** * The portion of the partial NOT yet committed to the conversation history * (completed tool rounds commit as they finish; only the interrupted * round's tail is uncommitted). */ readonly getUncommittedContent: () => string; /** Commit the interrupted tail (with its interruption note) to the model-facing history. */ readonly commitPartialToHistory: (content: string) => void; /** toolCallId -> toolName for every announced-but-unresolved tool call. */ readonly openToolCalls: Map; readonly wasCancelRequested: () => boolean; readonly isShutdown: () => boolean; readonly publish: (event: CompanionChatTurnEvent) => void; /** Push the partial into the transcript, update meta, persist. */ readonly persistPartial: (message: CompanionChatMessage) => void; /** Resolve the post's pending reply; `extra` carries the partial when one exists. */ readonly resolveReply: (extra: { assistantMessageId?: string; response?: string; }) => void; readonly settle: (result: TurnSettleResult) => void; } /** * The single exit path for an aborted turn (user cancel, session close, or * shutdown). Closes dangling tool blocks, persists a non-empty partial with * an explicit `deliveryState: 'cancelled'` marker (never a silent loss, never * a partial masquerading as a finished reply), commits the interrupted tail * to the model-facing conversation history with an explicit interruption note * (later turns must be able to reason about what the user saw and stopped), * and publishes the terminal `turn.cancelled` to every subscriber so a stop * issued from one client converges on all of them. */ export declare function finalizeCancelledTurn(ctx: CancelFinalizeContext): void; /** * Cancel the given active turn. Refusals are honest machine codes: * no turn in flight → 404 NO_ACTIVE_TURN (benign, the turn finished before * the stop landed); `turnId` guard mismatch → 409 TURN_MISMATCH (a newer turn * took the slot, a stale stop click must not kill it). Repeat cancels are * idempotent successes, never errors. The caller resolves the session * (SESSION_NOT_FOUND is its refusal). */ export declare function cancelActiveTurn(sessionId: string, turn: ActiveCompanionTurn | null, input: CancelCompanionChatTurnInput): Promise; export interface CompanionReplyWait { readonly messageId: string; readonly assistantMessageId?: string | undefined; readonly response?: string | undefined; readonly error?: string | undefined; } /** * Wrap a post in a bounded reply wait: resolves with the turn's reply result, * a timeout marker, or the post failure, never rejects. The `post` callback * receives the pending-reply record to register (resolve + its timeout handle) * and returns the message id; `onTimeout` lets the caller drop the * registration when the bound fires first. */ export declare function awaitCompanionReply(timeoutMs: number, post: (pendingReply: { readonly resolve: (result: CompanionReplyWait) => void; readonly timeout: ReturnType; }) => Promise, onTimeout: (messageId: string) => void): Promise; /** * A user message whose LLM turn has not started yet. The transcript message * is already appended (marked `deliveryState: 'queued'`); the provider-ready * conversation content is stashed here and committed to the conversation only * when the turn actually starts, committing at post time would leak the * queued message into the ACTIVE turn's later tool rounds, which re-read the * conversation every round. */ /** * What a caller may say about a message it is posting. * * Shared by `postMessage` and `steerMessage` because a steer is a message with * a queue position, not a different kind of thing, and when the two shapes * drifted apart it was possible to wire provenance into one and forget the * other, which is precisely the class of miss this field exists to prevent. */ export interface CompanionPostMessageOptions { readonly attachments?: readonly CompanionChatMessageAttachmentInput[] | undefined; readonly metadata?: Record | undefined; /** * True ONLY from a transport that authenticated the OWNER, it starts a new * untrusted-content turn. A caller that cannot prove who is speaking leaves * it unset; see platform/security/turn-boundary.ts for why that asymmetry * only works in one direction. */ readonly ownerDirect?: boolean | undefined; } export interface QueuedCompanionTurn { readonly userMessageId: string; /** * True when the transport that posted this message can honestly attest the * OWNER sent it, it authenticated them over the daemon's bearer-token API. * * It rides the queue entry rather than being read at turn-start because a * message can wait here behind an active turn, and by then the call that * knew who sent it is long gone. Absent means "could not establish it", * which is treated as not-the-owner: see platform/security/turn-boundary.ts * for why that asymmetry only works in one direction. */ readonly ownerDirect?: boolean | undefined; /** Provider-ready user content (attachments resolved at post time). */ readonly providerContent: Parameters[0]; /** In-process tap for this turn's incremental events (rides the queue entry). */ readonly onTurnEvent?: ((event: CompanionChatTurnEvent) => void) | undefined; } //# sourceMappingURL=companion-chat-turn-control.d.ts.map