Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 provider has no file upload channel — such videos now fall back to inline delivery.
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
---

Videos attached to a prompt — pasted in the TUI or uploaded in the web UI — now reach the model together with the prompt, with no extra tool round trip, and stay playable in the chat after a reload.
13 changes: 12 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,18 @@ export class KimiTUI {
this.showError(LLM_NOT_SET_MESSAGE);
return;
}
const extraction = extractMediaAttachments(text, this.imageStore);
let extraction: ReturnType<typeof extractMediaAttachments>;
try {
// Pasted videos are copied into the cache and expand to a `file://`
// `video_url` part; the engine resolves (uploads or degrades) them
// inside the turn, so submission stays fully synchronous.
extraction = extractMediaAttachments(text, this.imageStore);
} catch (error) {
// A video cache copy failed (unwritable cache dir, vanished source…);
// nothing was dispatched.
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
if (!this.validateMediaCapabilities(extraction)) return;
const session = this.session;
if (session === undefined) {
Expand Down
46 changes: 36 additions & 10 deletions apps/kimi-code/src/tui/utils/image-placeholder.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
/**
* 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:
* `extractMediaAttachments` (sync) is the single expansion path for prompts:
* - image placeholders expand to inline image content parts (preceded by a
* compression caption when paste-time compression shrank the bytes — see
* `ImageAttachment.original`);
* - video placeholders are copied into the shared cache (`getCacheDir()`)
* and expand to a `video_url` part pointing at the cache copy with a
* `file://` url. The v1 engine resolves that local reference inside the
* turn — uploading it (the `ms://` inline form) or degrading to a
* `<video path>` tag the model reads with `ReadMediaFile` — before the
* prompt lands in history.
*
* `rewriteMediaPlaceholders` is the separate text channel for slash-command
* args (`/skill`, plugin commands): those are plain text, so media is rendered
* as a `<video|image path="…">` tag / plain-text reference into cache-dir
* copies the model opens with `ReadMediaFile`.
*
* 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.
* - Order is preserved for text/image/video segments.
* - Adjacent text segments are flattened — empty / whitespace-only
* segments drop out so we never emit `{type:'text', text:' '}`
* noise between two media parts.
Expand All @@ -20,6 +31,7 @@
import { randomUUID } from 'node:crypto';
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

import type { PromptPart } from '@moonshot-ai/kimi-code-sdk';
import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk';
Expand Down Expand Up @@ -71,8 +83,10 @@ export function extractMediaAttachments(
const before = text.slice(cursor, match.index);
pushText(parts, before);
if (attachment.kind === 'video') {
// Copy the paste into the shared cache and reference it by a `file://`
// url; the engine resolves (uploads or degrades) it inside the turn.
const cachePath = materializeVideoToCache(attachment);
pushText(parts, formatMediaTag('video', cachePath));
parts.push(videoPartForCachePath(cachePath));
videoAttachmentIds.push(id);
} else {
// Paste-time compression is announced next to the image so the model
Expand Down Expand Up @@ -195,6 +209,18 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart {
};
}

/**
* A `video_url` prompt part pointing at a cache copy by `file://` url. The v1
* engine resolves the local reference in-turn (upload → `ms://`, or degrade to
* a `<video path>` tag) before it reaches the model or the persisted history.
*/
function videoPartForCachePath(cachePath: string): PromptPart {
return {
type: 'video_url',
videoUrl: { url: pathToFileURL(cachePath).href },
};
}

function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string {
const cacheDir = getCacheDir();
mkdirSync(cacheDir, { recursive: true });
Expand Down
34 changes: 20 additions & 14 deletions apps/kimi-code/test/tui/input/image-placeholder.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { describe, it, expect } from 'vitest';

Expand Down Expand Up @@ -40,16 +41,17 @@ function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), 'kimi-src-'));
}

type TextPart = { type: 'text'; text: string };
type VideoUrlPart = { type: 'video_url'; videoUrl: { url: string } };

// Prompt-attached videos are emitted as a `video_url` part whose url is a
// local `file://` reference to the cache copy; decode it back to a filesystem
// path for assertions.
function videoPathFromParts(parts: unknown[]): string {
const text = parts
.filter((p): p is TextPart => (p as TextPart).type === 'text')
.map((p) => p.text)
.join('');
const m = /<video path="([^"]+)"><\/video>/.exec(text);
if (!m) throw new Error(`no video tag found in: ${text}`);
return m[1]!;
const part = parts.find(
(p): p is VideoUrlPart => (p as VideoUrlPart).type === 'video_url',
);
if (!part) throw new Error(`no video_url part found in: ${JSON.stringify(parts)}`);
return fileURLToPath(part.videoUrl.url);
}

describe('extractMediaAttachments', () => {
Expand Down Expand Up @@ -135,26 +137,27 @@ describe('extractMediaAttachments', () => {
});
});

it('escapes media paths in generated tags', () => {
it('keeps the video label (including special chars) in the cache path', () => {
const { cleanup } = setupTempCache();
const srcDir = makeTempDir();
try {
const srcVideo = join(srcDir, 'source.mp4');
writeFileSync(srcVideo, 'x');
const store = new ImageAttachmentStore();
// The filename drives the cache label; `&` must be escaped in the attribute.
// The filename drives the cache label; `&` is a valid path char the cache
// copy keeps verbatim (the engine escapes it if it later renders a tag).
const att = store.addVideo('video/mp4', srcVideo, 'a&b.mp4');
const r = extractMediaAttachments(att.placeholder, store);
expect(r.parts).toHaveLength(1);
const text = (r.parts[0] as TextPart).text;
expect(text).toMatch(/<video path="[^"]+a&amp;b\.mp4"><\/video>/);
expect((r.parts[0] as VideoUrlPart).type).toBe('video_url');
expect(videoPathFromParts(r.parts).endsWith('a&b.mp4')).toBe(true);
} finally {
cleanup();
rmSync(srcDir, { recursive: true, force: true });
}
});

it('copies video placeholders into the cache and emits cache-path tags', () => {
it('copies video placeholders into the cache and emits a file:// video_url part', () => {
const { cleanup } = setupTempCache();
const srcDir = makeTempDir();
try {
Expand All @@ -165,8 +168,11 @@ describe('extractMediaAttachments', () => {
const r = extractMediaAttachments(att.placeholder, store);
expect(r.hasMedia).toBe(true);
expect(r.videoAttachmentIds).toEqual([1]);
const part = r.parts[0] as VideoUrlPart;
expect(part.type).toBe('video_url');
expect(part.videoUrl.url.startsWith('file:')).toBe(true);
const cachePath = videoPathFromParts(r.parts);
// The tag points at the cache, not the original source path.
// The part points at the cache copy, not the original source path.
expect(cachePath.startsWith(getCacheDir())).toBe(true);
expect(cachePath).not.toBe(srcVideo);
expect(readFileSync(cachePath, 'utf8')).toBe('video-data');
Expand Down
60 changes: 59 additions & 1 deletion apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ function makeSession(overrides: Record<string, unknown> = {}) {
id: 'ses-1',
model: 'k2',
summary: { title: null },
prompt: vi.fn(async () => {}),
prompt: vi.fn(async (_input: unknown) => {}),
compact: vi.fn(async () => {}),
steer: vi.fn(async () => {}),
init: vi.fn(async () => {}),
startBtw: vi.fn(async () => 'agent-btw'),
Expand Down Expand Up @@ -1554,6 +1555,63 @@ command = "vim"
expect(transcript).not.toContain('review');
});

it('sends a pasted video as a file:// video_url part', async () => {
const { driver, session } = await makeDriver();
const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore;
const dir = await mkdtemp(join(tmpdir(), 'tui-video-'));
try {
const srcVideo = join(dir, 'clip.mp4');
await writeFile(srcVideo, 'video-bytes');
const attachment = imageStore.addVideo('video/mp4', srcVideo);

// Submission is fully synchronous: the paste is copied to the cache and
// referenced by a `file://` video_url the engine resolves in-turn.
driver.handleUserInput(`watch ${attachment.placeholder}`);

const parts = vi.mocked(session.prompt).mock.calls[0]?.[0] as
| Array<{
type: string;
text?: string;
videoUrl?: { url: string };
}>
| undefined;
expect(parts?.[0]).toEqual({ type: 'text', text: 'watch ' });
expect(parts?.[1]?.type).toBe('video_url');
expect(parts?.[1]?.videoUrl?.url).toMatch(/^file:\/\/.*clip\.mp4$/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('queues a pasted video (file:// part) while a turn is streaming', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore;
const dir = await mkdtemp(join(tmpdir(), 'tui-video-'));
try {
const srcVideo = join(dir, 'clip.mp4');
await writeFile(srcVideo, 'video-bytes');
const attachment = imageStore.addVideo('video/mp4', srcVideo);
driver.state.appState.streamingPhase = 'waiting';

driver.handleUserInput(`describe ${attachment.placeholder}`);

expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toHaveLength(1);
const queued = driver.state.queuedMessages[0];
const parts = queued?.parts as Array<{ type: string; text?: string; videoUrl?: { url: string } }>;
expect(parts?.[0]).toEqual({ type: 'text', text: 'describe ' });
expect(parts?.[1]?.type).toBe('video_url');
expect(parts?.[1]?.videoUrl?.url).toMatch(/^file:\/\/.*clip\.mp4$/);

driver.sendQueuedMessage(session, queued!);
expect(session.prompt).toHaveBeenCalledWith(parts);
} finally {
await rm(dir, { recursive: true, force: true });
}
});


it('sends pasted image placeholders as image content parts', async () => {
const { driver, session } = await makeDriver();
const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore;
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function toAppImageSource(src: WireImageSource): ImageSource {
if (src.kind === 'file') {
return { kind: 'file', fileId: src.file_id };
}
return { kind: 'url', url: src.url };
return { kind: 'url', url: src.url, id: src.id };
}

export function toAppMessageContent(wire: WireMessageContent): AppMessageContent {
Expand Down Expand Up @@ -229,7 +229,7 @@ function toWireMessageContent(app: AppMessageContent): WireMessageContent {
} else if (src.kind === 'file') {
wireSrc = { kind: 'file', file_id: src.fileId };
} else {
wireSrc = { kind: 'url', url: src.url };
wireSrc = { kind: 'url', url: src.url, id: src.id };
}
return { type: app.type, source: wireSrc };
}
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export type WireMessageContent =
| { type: 'thinking'; thinking: string; signature?: string };

export type WireImageSource =
| { kind: 'url'; url: string }
| { kind: 'url'; url: string; id?: string }
| { kind: 'base64'; media_type: string; data: string }
| { kind: 'file'; file_id: string };

Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export type AppMessageContent =
| { type: 'unknown'; raw: unknown };

export type ImageSource =
| { kind: 'url'; url: string }
| { kind: 'url'; url: string; id?: string }
| { kind: 'base64'; mediaType: string; data: string }
| { kind: 'file'; fileId: string };

Expand Down
24 changes: 23 additions & 1 deletion apps/kimi-web/src/components/FilePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const rootRef = ref<HTMLElement | null>(null);
// Content type detection
// ---------------------------------------------------------------------------

type ContentKind = 'markdown' | 'json' | 'html' | 'pdf' | 'csv' | 'image' | 'text' | 'binary';
type ContentKind = 'markdown' | 'json' | 'html' | 'pdf' | 'csv' | 'image' | 'video' | 'text' | 'binary';

const contentKind = computed<ContentKind>(() => {
const f = props.file;
Expand All @@ -113,6 +113,7 @@ const contentKind = computed<ContentKind>(() => {
if (mime === 'application/pdf' || lowerPath.endsWith('.pdf')) return 'pdf';
if (mime === 'text/csv' || lang === 'csv' || lowerPath.endsWith('.csv')) return 'csv';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (f.isBinary) return 'binary';
// text/* and code files
if (mime.startsWith('text/') || lang !== '') return 'text';
Expand Down Expand Up @@ -309,6 +310,14 @@ const imageSrc = computed<string | null>(() => {
return null;
});

const videoSrc = computed<string | null>(() => {
const f = props.file;
if (!f || contentKind.value !== 'video') return null;
if (f.sourceUrl) return f.sourceUrl;
if (f.encoding === 'base64') return `data:${f.mime};base64,${f.content}`;
return null;
});

const pdfSrc = computed<string | null>(() => {
const f = props.file;
if (!f || contentKind.value !== 'pdf') return null;
Expand Down Expand Up @@ -626,6 +635,19 @@ function truncatePath(path: string, maxLen = 55): string {
</div>
</div>

<!-- Body: Video -->
<div v-else-if="contentKind === 'video'" class="fp-body fp-image-wrap">
<template v-if="videoSrc">
<video :src="videoSrc" class="fp-image" controls playsinline preload="metadata" />
</template>
<div v-else class="fp-binary-card">
<span class="fp-binary-icon">
<Icon name="image-off" size="lg" />
</span>
<span class="fp-binary-label">{{ t('filePreview.videoNoPreview', { mime: file.mime, size: formatSize(file.size) }) }}</span>
</div>
</div>

<!-- Body: Text/Code (with line numbers) -->
<div v-else-if="contentKind === 'text'" class="fp-body fp-code">
<div class="fp-line-table">
Expand Down
Loading
Loading