Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
128faf1
feat: send prompt-attached videos directly with the prompt
RealKai42 Jul 21, 2026
6946c57
fix: fall back to inline video when the upload channel fails
RealKai42 Jul 21, 2026
ead47c6
test: cover prompt video edge cases and failure paths
RealKai42 Jul 21, 2026
8881ef3
Merge remote-tracking branch 'origin/main' into feat/prompt-video-inline
RealKai42 Jul 21, 2026
64d95ef
fix: surface auth rejections from the video upload channel
RealKai42 Jul 21, 2026
3a4e720
fix: keep the by-design no-hook video error from degrading to inline
RealKai42 Jul 21, 2026
465e7a6
fix: constrain the llm video route param to a safe alphabet
RealKai42 Jul 21, 2026
172a531
style(agent-core-v2): move added explanations into top-of-file commen…
RealKai42 Jul 21, 2026
3e08864
fix: reject prompt video uploads when the model lacks video input
RealKai42 Jul 21, 2026
99cfdab
fix: sniff uploaded video bytes before inlining them
RealKai42 Jul 21, 2026
0f89d7e
test: keep the generation stub pending until the abort lands
RealKai42 Jul 21, 2026
b1c6e03
fix: validate prompt file references before mutating session controls
RealKai42 Jul 21, 2026
a9d0ff8
fix: serialize prompt submissions per session
RealKai42 Jul 21, 2026
ef197c2
fix: play reloaded provider videos through the authenticated fetch path
RealKai42 Jul 21, 2026
ece87aa
fix: fence pending video submits against session and model switches
RealKai42 Jul 21, 2026
b8077ec
fix: preserve the caller's video path verbatim in uploadVideo
RealKai42 Jul 21, 2026
259e4e6
fix: forward provider-issued ids on image URL prompt parts too
RealKai42 Jul 21, 2026
6f0c9ec
fix(web): reconcile inlined video echoes into the optimistic user mes…
RealKai42 Jul 21, 2026
f536783
fix: queue bash submits behind a pending video upload
RealKai42 Jul 21, 2026
a637ea5
test: cast the driver through unknown for the session-switch fence test
RealKai42 Jul 21, 2026
d922a4c
fix: validate prompt file references before resolving the prompt agent
RealKai42 Jul 21, 2026
defe33d
fix: key the playback mapping by the id embedded in the reference URL
RealKai42 Jul 21, 2026
c5f1a71
fix: sanitize provider video ids on the write side of the playback map
RealKai42 Jul 21, 2026
61db503
fix(web): keep recovered provider videos resendable through the edit …
RealKai42 Jul 21, 2026
8bb4bca
fix: fall back to inline video for no-hook providers whose wire carri…
RealKai42 Jul 21, 2026
4f7d3cd
test: satisfy the full IBlobStore shape in the playback-map stub
RealKai42 Jul 21, 2026
c5c370d
fix: queue prompt-producing slash commands behind pending uploads
RealKai42 Jul 21, 2026
5cb32bc
fix: validate media kinds in the prompt file-reference preflight
RealKai42 Jul 21, 2026
78061b9
fix(web): play sent and recovered videos in the file preview
RealKai42 Jul 21, 2026
a780bce
fix(web): preview recovered videos with the authenticated blob URL
RealKai42 Jul 21, 2026
399ae2e
style(agent-core-v2): move the inline-fallback note into the module h…
RealKai42 Jul 21, 2026
0df5e3b
fix: fence delayed bash submits to the originating session
RealKai42 Jul 21, 2026
e76851f
fix: emit prompt video telemetry from the agent scope
RealKai42 Jul 21, 2026
9a9f9d2
fix: preserve provider image ids through legacy projections and web m…
RealKai42 Jul 21, 2026
a08db8a
fix(web): revoke recovered video blob URLs before dropping attachments
RealKai42 Jul 21, 2026
37eacff
fix: serialize foreground slash commands behind pending uploads
RealKai42 Jul 21, 2026
45c3f81
fix: defer session controls until media preparation succeeds
RealKai42 Jul 21, 2026
d6f64ef
chore: bump the new SDK video upload API as a minor release
RealKai42 Jul 21, 2026
0998978
fix: re-check busy state before draining upload-queued commands
RealKai42 Jul 21, 2026
78f5ca9
fix: resolve profile-bound models before choosing the uploader
RealKai42 Jul 21, 2026
6a1bd26
refactor: resolve prompt videos at request time inside the engine
RealKai42 Jul 21, 2026
1c50a40
fix: propagate abort through video upload delivery instead of degrading
RealKai42 Jul 21, 2026
a32e0b6
fix: keep the tag form for no-upload providers whose wire drops inlin…
RealKai42 Jul 21, 2026
353210a
fix: escape the NUL separator in the video upload cache key
RealKai42 Jul 21, 2026
febd214
fix: check the abort signal before the inline video fallback
RealKai42 Jul 22, 2026
57af423
fix: retry transient prompt video upload failures on later steps
RealKai42 Jul 22, 2026
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
5 changes: 5 additions & 0 deletions .changeset/fix-read-media-video-upload-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix ReadMediaFile failing on videos when the model's provider has no usable file upload channel — the video now falls back to being sent inline.
5 changes: 5 additions & 0 deletions .changeset/prompt-video-inline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Send pasted videos to the model directly with the prompt instead of routing them through a ReadMediaFile tool call, so video input is faster and no longer depends on an extra tool round trip.
5 changes: 5 additions & 0 deletions .changeset/sdk-session-upload-video.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
---

Add Session.uploadVideo to upload a local video through the session's current model and embed the returned reference as a prompt part.
5 changes: 5 additions & 0 deletions .changeset/web-video-playback-after-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Keep uploaded videos playable in the chat after reloading or resuming a session.
64 changes: 52 additions & 12 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ import { isDeadTerminalError } from './utils/dead-terminal';
import { formatErrorMessage } from './utils/event-payload';
import { pickForegroundTasks } from './utils/foreground-task';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder';
import { extractMediaSegments, materializeMediaSegments, rewriteMediaPlaceholders, segmentsToPromptParts } from './utils/image-placeholder';
import { hasPatchChanges } from './utils/object-patch';
import { sessionRowsForPicker } from './utils/session-picker-rows';
import { formatBashOutputForDisplay } from './utils/shell-output';
Expand Down Expand Up @@ -1121,30 +1121,70 @@ export class KimiTUI {
this.updateQueueDisplay();
}

// Submits chained behind an in-flight video upload (see sendNormalUserInput).
private pendingSubmits = 0;
private inputSubmitChain: Promise<void> = Promise.resolve();

sendNormalUserInput(text: string): void {
if (this.btwPanelController.sendUserInput(text)) return;
if (this.state.appState.model.trim().length === 0) {
this.showError(LLM_NOT_SET_MESSAGE);
return;
}
const extraction = extractMediaAttachments(text, this.imageStore);
const extraction = extractMediaSegments(text, this.imageStore);
if (!this.validateMediaCapabilities(extraction)) return;
const session = this.session;
if (session === undefined) {
this.showError(LLM_NOT_SET_MESSAGE);
return;
}
if (extraction.hasMedia) {
this.sendMessage(session, text, {
hasMedia: true,
parts: extraction.parts,
imageAttachmentIds: extraction.imageAttachmentIds,
});
} else {
this.sendMessage(session, text);
const hasVideo = extraction.videoAttachmentIds.length > 0;
if (!hasVideo && this.pendingSubmits === 0) {
// Fully synchronous path: nothing to upload, nothing queued behind an
// upload, so ordering is trivially safe.
if (extraction.hasMedia) {
this.sendMessage(session, text, {
hasMedia: true,
parts: segmentsToPromptParts(extraction.segments),
imageAttachmentIds: extraction.imageAttachmentIds,
});
} else {
this.sendMessage(session, text);
}
this.updateQueueDisplay();
this.state.ui.requestRender();
return;
}
this.updateQueueDisplay();
this.state.ui.requestRender();
// Video attachments upload asynchronously before the prompt goes out.
// Chain this submit (and any submitted while an upload is in flight) so
// a slow upload cannot be overtaken by a later message.
this.pendingSubmits += 1;
this.inputSubmitChain = this.inputSubmitChain.then(async () => {
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
try {
if (extraction.hasMedia) {
const parts = hasVideo
? await materializeMediaSegments(extraction.segments, (attachment) =>
session.uploadVideo(attachment.sourcePath),
)
: segmentsToPromptParts(extraction.segments);
this.sendMessage(session, text, {
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
hasMedia: true,
parts,
imageAttachmentIds: extraction.imageAttachmentIds,
});
} else {
this.sendMessage(session, text);
}
} catch (error) {
// The tag fallback itself could not be prepared (unwritable cache
// dir, vanished video source…); nothing was dispatched.
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
} finally {
this.pendingSubmits -= 1;
}
this.updateQueueDisplay();
this.state.ui.requestRender();
});
}

validateMediaCapabilities(extraction: {
Expand Down
153 changes: 146 additions & 7 deletions apps/kimi-code/src/tui/utils/image-placeholder.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
/**
* Scan submitted text for media placeholders and produce
* the `PromptPart[]` we'll send to the SDK prompt endpoint.
* Scan submitted text for media placeholders and produce the prompt content
* we'll send to the SDK prompt endpoint.
*
* Rules:
* Two extraction paths share the same placeholder scan:
*
* - `extractMediaAttachments` (sync): image placeholders expand to image
* content parts; video placeholders are copied into the shared cache
* (`getCacheDir()`) and expand to `<video path="…">` file tags, so the
* model opens them with `ReadMediaFile`. Used by channels that cannot
* upload first (steer splice, `/skill` args via rewriteMediaPlaceholders,
* plugin commands) and as the fallback when a prompt-time video upload
* fails.
* - `extractMediaSegments` + `materializeMediaSegments` (async): video
* placeholders stay unresolved references until `materializeMediaSegments`
* uploads each one through the session's video upload channel and embeds
* the provider-issued `video_url` part directly in the prompt — the model
* receives the video with the user message, no tool call needed. An
* upload that fails falls back to the file-tag form above.
*
* Rules for both:
* - Only placeholders that resolve against `store` get extracted.
* A literal `[image #999 ...]` the user typed themselves stays in
* the text (we can't hallucinate files for it).
* - Order is preserved for text/image/video segments. Image placeholders
* expand to image content parts so the prompt reaches the provider
* without relying on a model tool call. Video placeholders are copied
* into the shared cache (`getCacheDir()`) and expand to file-path tags,
* so `ReadMediaFile` — and the provider's `VideoUploader` — own video
* upload behavior instead of base64-inlining here.
* without relying on a model tool call.
* - Adjacent text segments are flattened — empty / whitespace-only
* segments drop out so we never emit `{type:'text', text:' '}`
* noise between two media parts.
Expand Down Expand Up @@ -101,6 +114,118 @@ export function extractMediaAttachments(
};
}

export interface MediaSegmentPart {
readonly kind: 'part';
readonly part: PromptPart;
}

export interface MediaSegmentVideo {
readonly kind: 'video';
readonly attachment: VideoAttachment;
}

export type MediaSegment = MediaSegmentPart | MediaSegmentVideo;

export interface SegmentExtractionResult {
/** Ordered segments: text/image parts inline, videos as unresolved refs. */
readonly segments: MediaSegment[];
readonly hasMedia: boolean;
readonly imageAttachmentIds: number[];
readonly videoAttachmentIds: number[];
}

/**
* Variant of `extractMediaAttachments` that leaves video placeholders
* unresolved: the returned segments keep the `VideoAttachment` so the caller
* can upload it (async) before deciding the final part shape. No cache copy
* happens here, so unlike `extractMediaAttachments` this never throws for a
* vanished video source — that surfaces only if the tag fallback runs.
*/
export function extractMediaSegments(
text: string,
store: ImageAttachmentStore,
): SegmentExtractionResult {
const segments: MediaSegment[] = [];
const imageAttachmentIds: number[] = [];
const videoAttachmentIds: number[] = [];
let cursor = 0;
let hasMedia = false;

PLACEHOLDER_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) {
const [literal, kind, idStr] = match;
if (kind !== 'image' && kind !== 'video') continue;
if (idStr === undefined) continue;
const id = Number.parseInt(idStr, 10);
const attachment = store.get(id);
if (attachment === undefined) continue; // stale / user-typed — leave as text
if (attachment.kind !== kind) continue;
const before = text.slice(cursor, match.index);
pushTextPart(segments, before);
if (attachment.kind === 'video') {
segments.push({ kind: 'video', attachment });
videoAttachmentIds.push(id);
} else {
if (attachment.original !== undefined) {
pushTextPart(segments, captionForCompressedImage(attachment));
}
segments.push({ kind: 'part', part: imagePartForAttachment(attachment) });
imageAttachmentIds.push(id);
}
hasMedia = true;
cursor = match.index + literal.length;
}
pushTextPart(segments, text.slice(cursor));

return {
segments: hasMedia ? segments : [],
hasMedia,
imageAttachmentIds,
videoAttachmentIds,
};
}

/**
* Resolve extracted segments into final prompt parts. Text and image
* segments pass through; each video attachment is uploaded through `upload`
* (the session's video upload channel) so the prompt embeds the
* provider-issued `video_url` part directly. When an upload fails the video
* degrades to a `<video path="…">` tag pointing at a cache copy, which the
* model can open with `ReadMediaFile` — uploads run concurrently, order is
* preserved. Throws only when a fallback cache copy itself fails.
*/
export async function materializeMediaSegments(
segments: readonly MediaSegment[],
upload: (attachment: VideoAttachment) => Promise<PromptPart>,
): Promise<PromptPart[]> {
const resolved = await Promise.all(
segments.map(async (segment): Promise<PromptPart[]> => {
if (segment.kind === 'part') return [segment.part];
try {
return [await upload(segment.attachment)];
} catch {
// Fall back to the file-tag form: the model reads the video itself.
const cachePath = materializeVideoToCache(segment.attachment);
return [{ type: 'text', text: formatMediaTag('video', cachePath) }];
}
}),
);
return resolved.flat();
}

/**
* Resolve already-extracted segments that contain no video placeholders into
* prompt parts synchronously (all segments are inline parts by then).
*/
export function segmentsToPromptParts(segments: readonly MediaSegment[]): PromptPart[] {
const parts: PromptPart[] = [];
for (const segment of segments) {
if (segment.kind === 'part') parts.push(segment.part);
}
return parts;
}

export interface MediaTagRewriteResult {
/** Input text with resolved placeholders replaced by media references. */
text: string;
Expand Down Expand Up @@ -187,6 +312,20 @@ function pushText(parts: PromptPart[], segment: string): void {
parts.push({ type: 'text', text: segment });
}

function pushTextPart(segments: MediaSegment[], segment: string): void {
if (segment.length === 0) return;
if (segment.trim().length === 0) return;
const last = segments.at(-1);
if (last?.kind === 'part' && last.part.type === 'text') {
segments[segments.length - 1] = {
kind: 'part',
part: { type: 'text', text: last.part.text + segment },
};
return;
}
segments.push({ kind: 'part', part: { type: 'text', text: segment } });
}

function imagePartForAttachment(att: ImageAttachment): PromptPart {
const base64 = Buffer.from(att.bytes).toString('base64');
return {
Expand Down
Loading