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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ for await (const chunk of result.textStream) {
- `streamingInput: 'always'` or `'auto'` with `canUseTool` set
- Messages injected via `inject(content)` are delivered to the agent mid-turn

**Important:** Injection works between tool calls, not during continuous text generation. Use tasks that involve tool usage (file operations, bash commands, etc.) for effective mid-turn interruption.
**Important:** Tool boundaries provide immediate mid-turn uptake. Continuous text generation has no fold-in boundary; opt-in continuation mode below keeps queued follow-up output in the same SDK stream.

**Use Cases:**

Expand All @@ -526,7 +526,7 @@ for await (const chunk of result.textStream) {

**API:**

- `inject(content: string, onResult?: (delivered: boolean) => void)` - Inject a user message. Optional callback reports delivery status.
- `inject(content: string, onResult?: (delivered: boolean) => void, options?: { uuid?: string })` - Inject a user message. Optional callback reports delivery status.
- `close()` - Signal no more messages will be injected

**Delivery Tracking:**
Expand All @@ -542,6 +542,19 @@ injector.inject('STOP!', (delivered) => {

See [examples/message-injection.ts](examples/message-injection.ts) for complete examples including conditional injection and supervisor approval patterns.

### One stream for injected continuations

`continueInjectedTurns: true` is an opt-in streaming mode for messages submitted through the `onStreamStart` MessageInjector. It requires that callback; direct `Query.streamInput()` writes are not tracked. The provider keeps reading when a native `result` leaves injected UUIDs unconfirmed, so a queued CLI continuation stays in the same AI SDK stream. It emits one final finish and sums the per-turn token usage from the native results. The latest cumulative `costUsd` and `modelUsage` snapshots are retained rather than added together.

- Pass `{ uuid }` as the third argument to `injector.inject(text, onDelivery, { uuid })` to retain your message identity. The provider also UUID-stamps the initial prompt. `onDelivery(true)` confirms stdin delivery, not absorption.
- `onStreamResult` reports confirmed and pending UUIDs. `onStreamConsumption` reports main-thread reply attribution; it is not a final absorption verdict.
- `onStreamEnd` runs synchronously once with `completed`, `interrupted`, or `timed_out`, before queued delivery callbacks are rejected. Detach the old injector on interruption and retain unconfirmed messages for a caller-controlled recovery stream; close admission on successful completion. Callback exceptions are isolated from provider cleanup.
- `injectionContinuationTimeoutMs` defaults to 30 seconds and is limited to 5 minutes. Its absolute budget starts at the first result with pending injections and is not reset by new messages or activity. A recovery initial-command completion can start the same budget while its matching result is still missing. At expiry the Query closes and `continuationTimedOut` is reported. A caller must invalidate native resume handles containing unconfirmed messages.
- Interrupted streams retain only usage already confirmed by native results, using an error finish with `partial: true`. Unknown usage from unfinished calls is not estimated.
- `resumedInjectionUuids` is an advanced recovery option, valid only with an explicit matching `resume`. Supply only immutable old IDs whose delivery and main-thread consumption were witnessed in that same native session. A matching initial-prompt success result and a duplicate completion receipt are both required. A current queued/started/cancelled or new consumption frame disables that fallback for the ID; its own result must then confirm it. New-stream IDs are never recovery candidates.

This option requires streaming generation; `doGenerate` rejects it. Without the opt-in, the existing first-result completion behavior is preserved. The caller owns deduplication across streams, platform message status, and the original turn deadline.

## Image Inputs (Streaming Only)

- Enable streaming input (`streamingInput: 'always'` or provide `canUseTool`) before sending images.
Expand Down
2 changes: 2 additions & 0 deletions docs/ai-sdk-v5/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

For the current AI SDK v6 provider (3.6.0+), `resumeDropsTurn`, `perTaskStopAffordance`, `permissionPrompts`, and `pluginDelivery` are documented in the [compatibility update](../../README.md#v360-compatibility-update); guarded rewind and model reuse are covered in [session management](../sessions.md#guarded-rewinds-v360). These additions do not apply to the legacy provider versions covered by this guide.

Current opt-in injected-turn continuation and stream lifecycle callbacks are documented under [Mid-Session Message Injection](../../README.md#mid-session-message-injection). They are not available in the legacy provider versions described here.

## Essential Examples

### Streaming Responses
Expand Down
106 changes: 95 additions & 11 deletions src/claude-code-language-model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto';
import { InjectionSession } from './injection-session.js';
import type {
LanguageModelV3,
LanguageModelV3Content,
Expand Down Expand Up @@ -727,6 +729,7 @@ type ToolStreamState = {
*/
type QueuedInjection = {
content: string;
uuid: string;
onResult?: (delivered: boolean) => void;
};

Expand All @@ -738,7 +741,7 @@ type QueuedInjection = {
* Note: getNextItem returns the full QueuedInjection so the consumer can call onResult
* AFTER successfully yielding, avoiding a race condition with outputStreamEnded.
*/
function createMessageInjector(): {
function createMessageInjector(session?: InjectionSession): {
injector: MessageInjector;
getNextItem: () => Promise<QueuedInjection | null>;
notifySessionEnded: () => void;
Expand All @@ -748,13 +751,14 @@ function createMessageInjector(): {
let resolver: ((item: QueuedInjection | null) => void) | null = null;

const injector: MessageInjector = {
inject(content, onResult) {
if (closed) {
inject(content, onResult, options) {
const uuid = options?.uuid ?? randomUUID();
if (closed || (session && !session.register(uuid))) {
// Already closed - immediately notify not delivered
onResult?.(false);
return;
}
const item: QueuedInjection = { content, onResult };
const item: QueuedInjection = { content, onResult, uuid };
if (resolver) {
// Consumer is waiting, resolve immediately
const r = resolver;
Expand Down Expand Up @@ -819,7 +823,8 @@ function toAsyncIterablePrompt(
outputStreamEnded: Promise<unknown>,
sessionId?: string,
contentParts?: SDKUserMessage['message']['content'],
onStreamStart?: (injector: MessageInjector) => void
onStreamStart?: (injector: MessageInjector) => void,
injectionSession?: InjectionSession
): AsyncIterable<SDKUserMessage> {
const content = (
contentParts && contentParts.length > 0
Expand All @@ -835,6 +840,9 @@ function toAsyncIterablePrompt(
},
parent_tool_use_id: null,
session_id: sessionId ?? '',
...(onStreamStart
? { uuid: (injectionSession?.initialUuid ?? randomUUID()) as SDKUserMessage['uuid'] }
: {}),
};

// If no callback, use simple behavior (backwards compatible)
Expand All @@ -848,7 +856,7 @@ function toAsyncIterablePrompt(
}

// With injection support: create injector and yield messages as they arrive
const { injector, getNextItem, notifySessionEnded } = createMessageInjector();
const { injector, getNextItem, notifySessionEnded } = createMessageInjector(injectionSession);

return {
async *[Symbol.asyncIterator]() {
Expand Down Expand Up @@ -887,6 +895,7 @@ function toAsyncIterablePrompt(
},
parent_tool_use_id: null,
session_id: sessionId ?? '',
uuid: item.uuid as SDKUserMessage['uuid'],
};
yield sdkMsg;

Expand Down Expand Up @@ -2412,6 +2421,9 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
async doGenerate(
options: Parameters<LanguageModelV3['doGenerate']>[0]
): Promise<Awaited<ReturnType<LanguageModelV3['doGenerate']>>> {
if (this.settings.continueInjectedTurns) {
throw new Error('continueInjectedTurns requires streaming generation');
}
this.logger.debug(`[claude-code] Starting doGenerate request with model: ${this.modelId}`);
this.logger.debug(`[claude-code] Response format: ${options.responseFormat?.type ?? 'none'}`);

Expand Down Expand Up @@ -3118,6 +3130,9 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
async doStream(
options: Parameters<LanguageModelV3['doStream']>[0]
): Promise<Awaited<ReturnType<LanguageModelV3['doStream']>>> {
if (this.settings.continueInjectedTurns && !this.settings.onStreamStart) {
throw new Error('continueInjectedTurns requires onStreamStart injection');
}
this.logger.debug(`[claude-code] Starting doStream request with model: ${this.modelId}`);
this.logger.debug(`[claude-code] Response format: ${options.responseFormat?.type ?? 'none'}`);

Expand Down Expand Up @@ -3201,6 +3216,27 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
const outputStreamEnded = new Promise((resolve) => {
done = () => resolve(undefined);
});
let providerFinishEmitted = false;
let streamEndReason: 'completed' | 'interrupted' | 'timed_out' = 'interrupted';
let streamEndNotified = false;
const notifyStreamEnd = () => {
if (streamEndNotified) return;
streamEndNotified = true;
try {
this.settings.onStreamEnd?.(injectionSession.timedOut ? 'timed_out' : streamEndReason);
} catch {
this.logger.warn('[claude-code] Stream end callback failed');
}
};
const injectionSession = new InjectionSession(
this.settings.injectionContinuationTimeoutMs ?? 30_000,
this.settings.onStreamResult,
{
sessionId: effectiveResume === this.settings.resume ? effectiveResume : undefined,
uuids: this.settings.resumedInjectionUuids,
onConsumption: this.settings.onStreamConsumption,
}
);
const toolStates = new Map<string, ToolStreamState>();
// Tombstones for pending tool calls dropped by supersede retraction:
// late tool_result/tool_error blocks for these IDs must be discarded,
Expand Down Expand Up @@ -3474,7 +3510,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
outputStreamEnded,
effectiveResume,
streamingContentParts,
this.settings.onStreamStart
this.settings.onStreamStart,
injectionSession
)
: messagesPrompt;

Expand All @@ -3491,7 +3528,11 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
// like mid-stream message injection via query.streamInput()
this.settings.onQueryCreated?.(response);

for await (const message of response) {
const responseMessages =
this.settings.continueInjectedTurns && this.settings.onStreamStart
? injectionSession.messages(response, () => response.close())
Comment on lines +3531 to +3533

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve permission denials from intermediate results

When a non-final native result contains permission_denials that the final result does not repeat, this wrapper hides that result from the main loop's mergeResultPermissionDenials() call. As that helper and its existing tests document, PreToolUse-hook denials can have no corresponding permission_denied system event, so this removes their only source and silently omits denied tool calls from the final provider metadata. A two-result runtime probe reproduced the omission. Merge each native result's denial metadata before filtering it, or aggregate and deduplicate these lists across intermediate results.

Useful? React with 👍 / 👎.

: response;
for await (const message of responseMessages) {
this.logger.debug(`[claude-code] Stream received message type: ${message.type}`);

// Handle streaming events (token-by-token delivery via includePartialMessages)
Expand Down Expand Up @@ -4337,7 +4378,6 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
);
}
} else if (message.type === 'result') {
done();
this.mergeResultPermissionDenials(message, metadataTracking);

// Handle is_error flag in result message (e.g., auth failures).
Expand Down Expand Up @@ -4506,13 +4546,21 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
// Prepare JSON-safe warnings for provider metadata
const warningsJson = this.serializeWarningsForMetadata(streamWarnings);

providerFinishEmitted = true;
controller.enqueue({
type: 'finish',
finishReason,
usage,
providerMetadata: {
'claude-code': {
sessionId: message.session_id,
...(this.settings.continueInjectedTurns
? {
userMessageUuids: [...injectionSession.absorbed],
unabsorbedUserMessageUuids: [...injectionSession.pending],
continuationTimedOut: injectionSession.timedOut,
}
: {}),
...(message.total_cost_usd !== undefined && {
costUsd: message.total_cost_usd,
}),
Expand Down Expand Up @@ -4559,6 +4607,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
},
},
});
streamEndReason = 'completed';
notifyStreamEnd();
controller.close();

// The prompt_suggestion message (promptSuggestions: true) arrives
Expand Down Expand Up @@ -4613,13 +4663,20 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
this.logger.debug('[claude-code] Stream finalized, closing stream');
controller.close();
} catch (error: unknown) {
done();
if (this.settings.continueInjectedTurns && providerFinishEmitted) return;
const knownResult = this.settings.continueInjectedTurns
? injectionSession.confirmedResult
: undefined;

this.logger.debug(
`[claude-code] Error during doStream: ${error instanceof Error ? error.message : String(error)}`
);

if (isClaudeCodeTruncationError(error, accumulatedText)) {
if (
!knownResult &&
!(this.settings.continueInjectedTurns && injectionSession.pending.size > 0) &&
isClaudeCodeTruncationError(error, accumulatedText)
) {
this.logger.warn(
`[claude-code] Detected truncated stream response, returning ${accumulatedText.length} characters of buffered text`
);
Expand Down Expand Up @@ -4652,6 +4709,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {

const warningsJson = this.serializeWarningsForMetadata(streamWarnings);

providerFinishEmitted = true;
controller.enqueue({
type: 'finish',
finishReason: { unified: 'length', raw: 'truncation' },
Expand Down Expand Up @@ -4679,6 +4737,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
},
});

streamEndReason = 'completed';
notifyStreamEnd();
controller.close();
return;
}
Expand All @@ -4699,9 +4759,33 @@ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
type: 'error',
error: errorToEmit,
});
if (knownResult && !providerFinishEmitted) {
providerFinishEmitted = true;
controller.enqueue({
type: 'finish',
finishReason: { unified: 'error', raw: 'injected_stream_interrupted' },
usage: convertClaudeCodeUsage(knownResult.usage),
providerMetadata: {
'claude-code': {
sessionId: knownResult.session_id,
partial: true,
...(knownResult.total_cost_usd !== undefined && {
costUsd: knownResult.total_cost_usd,
}),
...(knownResult.modelUsage !== undefined && {
modelUsage: knownResult.modelUsage as unknown as JSONValue,
}),
},
},
});
}

controller.close();
} finally {
// Invalidate the caller's old channel synchronously, before done()
// rejects queued writes or recovery can start on another microtask.
notifyStreamEnd();
done();
Comment on lines +4787 to +4788

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Close the injection channel before post-result draining

With continuation disabled, streaming input enabled, and onStreamStart plus an active onPromptSuggestion callback configured, deferring done() until this finally keeps the injector open throughout the post-result suggestion drain, potentially for 10 seconds after the AI SDK stream finishes. A late injection is now accepted and written to the native query while the drain discards continuation output. Replaying the parent and target implementations confirmed that the parent reports delivery as false, whereas this commit sends a second input message and reports true. End streaming input at the terminal result, after the synchronous lifecycle notification but before awaiting the drain.

Useful? React with 👍 / 👎.

if (options.abortSignal && abortListener) {
options.abortSignal.removeEventListener('abort', abortListener);
}
Expand Down
Loading