kimi-code/packages/acp-adapter/test/cancel.test.ts
Kai 474ce289dd
fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px (#1460)
* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px

Image compression now reports original dimensions in the decoded
(EXIF-rotated) space, matching the coordinate system of the sent image
and of ReadMediaFile region readback; previously portrait JPEGs
(orientation 5-8) got swapped width/height in captions. The longest-edge
downscale cap rises from 2000px to 3000px, and the default jimp resize
path is documented as the anti-aliased area-average one so it is not
accidentally switched to a point-sampled interpolation mode.

* test: shrink oversized image fixtures to fit CI timeouts

The 3600x3600 fixtures introduced for the 3000px edge cap nearly doubled
the pixel area jimp has to decode and deflate, pushing the slowest
compression tests past the 5s vitest timeout on CI runners. 3600x1800
keeps every fixture over the cap while restoring roughly the workload of
the old 2600x2600 fixtures that CI handled comfortably.

* test: pin anti-aliased downscale quality with executable guards

A 1px checkerboard probe pins the compressor to full-coverage averaging
at integer and fractional ratios, with jimp's point-sampled BILINEAR
mode kept as the executable aliasing counter-example (it collapses the
50%-gray pattern to solid black at 4:1). Also guards the other classic
downscale bugs: transparent-pixel color bleed, mean-brightness drift,
iterative recompression degradation, and zero-size collapse on extreme
aspect ratios.

* fix(agent-core): report decoded EXIF-rotated dimensions in ReadMediaFile notes

The media note derived its original-dimensions line from the header
sniff, which reports pre-rotation values for EXIF orientation 5-8
JPEGs. The sent image and region readback both live in the decoded
(rotated) space, so portrait photos got axis-swapped coordinate
guidance. Once a decode has happened — compression or crop — its
dimensions now overwrite the sniffed ones.

* fix(agent-core): improve handling of EXIF orientation in image dimensions and metadata

* fix(agent-core): sniff EXIF orientation and step budget fallback through 2000px

Two follow-ups to the EXIF and 3000px-cap changes:

sniffImageDimensions now reads the JPEG EXIF Orientation tag (pure
header parse, both byte orders) and reports display-space dimensions
for orientations 5-8. Passthrough images — never decoded — previously
kept the pre-rotation header size in compression results and media
read notes, disagreeing with the decoded space that region readback
uses.

encodeWithinBudget steps the over-budget fallback through 2000px
before the 1000px last resort. Raising the cap to 3000px had left a
regression window: an image whose 2000px encode fits the byte budget
was sent at 1000px where the old 2000px cap used to send it at
2000px.

* fix(kimi-code): record pasted image dimensions in display space

The TUI paste path recorded attachment and original dimensions from its
raw header parser, which ignores EXIF orientation. For a portrait JPEG
the submit-time caption then contradicted the sent image's aspect and
region readback coordinates were axis-swapped. Dimensions now come from
the compression result, which reports display space on both the
compressed and passthrough paths; parseImageMeta remains only the
format/mime gate.

* feat(agent-core): add image compression and crop telemetry

Every image ingestion path now reports an image_compress event —
outcome (compressed / passthrough fast, guard, unsupported, unhelpful,
error), input/output formats, byte and pixel sizes, EXIF transposition,
and duration — and region readback reports an image_crop event with a
failure classification and the region's share of the original area.

Wiring is per call site via a new CompressImageOptions.telemetry
option, so the outcome split and timing are measured inside the
compressor while each caller only names its source: ReadMediaFile
(tool construction, like GrepTool), MCP tool results (McpOutputOptions),
server prompt ingestion (ICoreProcessService now exposes the host
telemetry client), ACP prompts (session track adapter), and TUI paste
(host.track adapter). Properties are numeric/enum only — never paths
or content — and a throwing client can never affect the compression
result.

* fix(agent-core): run the full JPEG quality ladder at fallback sizes

The fallback rescales encoded only at quality 20, so a JPEG whose
ladder failed at the fitted size collapsed straight to the lowest
quality even when the smaller size left budget headroom for a higher
rung (the realistic window is the 1000px step, where the 4x pixel
drop pays for q80/q60). Each fallback edge now walks the same
q80-to-q20 ladder as the fitted size.

* test: shrink heavy JPEG fixtures and add explicit timeouts

The fallback-ladder test runs ~11 pure-JS JPEG encodes and the EXIF
paste test decodes, rotates, and re-encodes a 6.5MP frame; both sat at
the edge of the 5s vitest timeout on CI runners. Narrower fixtures cut
the pixel area (the ladder test keeps its width above 2000px so the
full fallback chain still runs) and explicit 15s timeouts absorb runner
variance.

* fix(server): scope prompt image compression telemetry to the session

The prompt-ingestion image_compress events were emitted with the bare
host telemetry client, while every agent-side source inherits a
session-scoped client — so prompt_inline/prompt_file events could not
be correlated with their session. The route now wraps the client with
withTelemetryContext({ sessionId }) like rpc/core-impl does for
session telemetry.

* chore(changeset): consolidate image compression changesets

One entry covering the cap raise and the EXIF dimension fix, listed
for both the CLI and the SDK so the SDK changelog's compression
description (previously pinned at 2000px) stays accurate.
2026-07-07 21:38:43 +08:00

221 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AgentSideConnection,
ClientSideConnection,
ndJsonStream,
type Client,
type ReadTextFileRequest,
type ReadTextFileResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import { log, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk';
import { Jimp } from 'jimp';
import { AcpServer } from '../src/server';
import { AUTHED_STATUS } from './_helpers/harness-stubs';
class StubClient implements Client {
async requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
throw new Error('StubClient.requestPermission should not be called in cancel test');
}
async sessionUpdate(_n: SessionNotification): Promise<void> {
throw new Error('StubClient.sessionUpdate should not be called in cancel test');
}
async writeTextFile(_p: WriteTextFileRequest): Promise<WriteTextFileResponse> {
throw new Error('StubClient.writeTextFile should not be called in cancel test');
}
async readTextFile(_p: ReadTextFileRequest): Promise<ReadTextFileResponse> {
throw new Error('StubClient.readTextFile should not be called in cancel test');
}
}
function makeInMemoryStreamPair(): {
agentStream: ReturnType<typeof ndJsonStream>;
clientStream: ReturnType<typeof ndJsonStream>;
} {
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>();
const agentToClient = new TransformStream<Uint8Array, Uint8Array>();
const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable);
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable);
return { agentStream, clientStream };
}
describe('AcpServer cancel', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('forwards session/cancel to the underlying Session.cancel() for a known sessionId', async () => {
let cancelCalls = 0;
const fakeSession = {
id: 'sess-known',
prompt: async () => undefined,
cancel: async () => {
cancelCalls += 1;
},
onEvent: () => () => undefined,
} as unknown as Session;
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => fakeSession,
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
// session/cancel is a notification — `client.cancel` is fire-and-forget.
await client.cancel({ sessionId: 'sess-known' });
// Give the agent side a tick to process the notification.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(cancelCalls).toBe(1);
expect(warnSpy).not.toHaveBeenCalled();
});
it('does not throw and logs a warning when sessionId is unknown', async () => {
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => {
throw new Error('createSession should not be called when no session is created');
},
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
// Notification: no response, no throw.
await client.cancel({ sessionId: 'sess-unknown' });
// Give the agent side a tick to process the notification.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('cancel for unknown sessionId'),
expect.objectContaining({ sessionId: 'sess-unknown' }),
);
});
it('swallows and warns when Session.cancel() throws (notifications must not error)', async () => {
const fakeSession = {
id: 'sess-erroring',
prompt: async () => undefined,
cancel: async () => {
throw new Error('boom inside cancel');
},
onEvent: () => () => undefined,
} as unknown as Session;
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => fakeSession,
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
await client.cancel({ sessionId: 'sess-erroring' });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('error while cancelling'),
expect.objectContaining({ sessionId: 'sess-erroring' }),
);
});
it('returns cancelled without launching when cancel arrives during image compression', async () => {
let promptCalls = 0;
const fakeSession = {
id: 'sess-cancel-compress',
prompt: async () => {
promptCalls += 1;
return undefined;
},
cancel: async () => undefined,
onEvent: () => () => undefined,
} as unknown as Session;
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => fakeSession,
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
// A solid 3600×1800 image is small in bytes but slow enough to compress
// that the cancel below reliably lands mid-compression, before any turn
// — while staying safely inside the 5s test timeout on slow CI runners.
const data = Buffer.from(
await new Jimp({ width: 3600, height: 1800, color: 0x3366ccff }).getBuffer('image/png'),
).toString('base64');
const promptP = client.prompt({
sessionId,
prompt: [{ type: 'image', data, mimeType: 'image/png' }],
});
await client.cancel({ sessionId });
const res = await promptP;
expect(res.stopReason).toBe('cancelled');
expect(promptCalls).toBe(0); // the turn was never launched
});
it('cancels every prompt compressing concurrently, not just the most recent', async () => {
let promptCalls = 0;
const fakeSession = {
id: 'sess-cancel-concurrent',
prompt: async () => {
promptCalls += 1;
return undefined;
},
cancel: async () => undefined,
onEvent: () => () => undefined,
} as unknown as Session;
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => fakeSession,
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
const data = Buffer.from(
await new Jimp({ width: 3600, height: 1800, color: 0x3366ccff }).getBuffer('image/png'),
).toString('base64');
const imageBlock = { type: 'image' as const, data, mimeType: 'image/png' };
// Two prompts compressing at once; a single cancel must cover both.
const p1 = client.prompt({ sessionId, prompt: [imageBlock] });
const p2 = client.prompt({ sessionId, prompt: [imageBlock] });
await client.cancel({ sessionId });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.stopReason).toBe('cancelled');
expect(r2.stopReason).toBe('cancelled');
expect(promptCalls).toBe(0);
});
});