mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 22:55:55 +00:00
* refactor(agent-core-v2): decouple workspace from session DI via runtime binding * fix(agent-core-v2): unblock session external hooks and scope workspaceMcp seeds - externalHooksService: inject App-level ISessionManager instead of the unregistered ISessionLifecycleService so SessionStart/SessionEnd hooks actually activate in production; keep sessionId matching and tolerate absent lifecycle events - workspaceMcpService: ignore onWillCreateSession events whose session belongs to another workspace, preventing cross-workspace ISessionMcpHandle seed overrides - update externalHooks integration tests, agent harness, and workspaceMcp tests; add reloadSources coverage in skillCatalog tests * fix(agent-core-v2): honor the bound runtime in prompt context, swarm spawn, and ACP sessions - map system-prompt cwd, directory listing, and additional dirs through RuntimeWorkspaceView, and skip the listing when the bound runtime has no fs capability - pass the caller agent's runtime binding to AgentSwarm child creation and prompt-prefix execution instead of hardcoding local - expose the ACP client filesystem through the ACP session runtime and build its shell/path environment from the probed host instead of hardcoded Linux - dispatch klient facade createChild to sessionManager.createChild so child sessions keep their parent markers * fix(agent-core-v2): resolve routed fs and tool paths with runtime path semantics - WorkspaceFsService resolves via the bound runtime's RuntimePath (extended with basename/dirname) instead of node:path, so mapped roots such as C:\\repo stay runtime-local. - Read/Write/Glob/Grep pass skill roots through mapRoots via RuntimeWorkspaceView input, matching Edit. - acp-server unbinds session runtimes on session/close, not only on delete. - apps/kimi-code drops the /runtime slash command; SDK runtime methods stay. * fix(agent-core-v2): retire idle session controllers, untrack disposed runtime resources, and rebuild fs watches on generation replace * fix(agent-core-v2): resolve oxlint errors in runtime lifecycle fixes * fix(kap-server): untrack download stream from runtime generation on completion * fix(kap-server): drop meaningless void operator on tracked dispose
166 lines
6.2 KiB
TypeScript
166 lines
6.2 KiB
TypeScript
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
import type { ContentPart } from '@moonshot-ai/agent-core-v2';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
acpBlocksToContentParts,
|
|
acpMcpServersToConfigRecord,
|
|
compressPromptImageParts,
|
|
} from '../src/convert';
|
|
import { solidPng, solidPngBase64 } from './_helpers/png';
|
|
|
|
describe('acpMcpServersToConfigRecord', () => {
|
|
it('returns undefined for an absent or empty list', () => {
|
|
expect(acpMcpServersToConfigRecord(undefined)).toBeUndefined();
|
|
expect(acpMcpServersToConfigRecord([])).toBeUndefined();
|
|
});
|
|
|
|
it('rejects stdio servers that cannot declare a runtime identity', () => {
|
|
const servers: McpServer[] = [
|
|
{
|
|
name: 'fs',
|
|
command: '/usr/local/bin/mcp-fs',
|
|
args: ['--root', '/tmp'],
|
|
env: [
|
|
{ name: 'API_KEY', value: 'secret' },
|
|
{ name: 'DEBUG', value: '1' },
|
|
],
|
|
},
|
|
];
|
|
expect(() => acpMcpServersToConfigRecord(servers)).toThrow(
|
|
'ACP stdio MCP server fs does not declare a runtime identity',
|
|
);
|
|
});
|
|
|
|
it('maps http and sse servers with header pairs as a record', () => {
|
|
const servers: McpServer[] = [
|
|
{
|
|
type: 'http',
|
|
name: 'web',
|
|
url: 'https://example.com/mcp',
|
|
headers: [{ name: 'Authorization', value: 'Bearer x' }],
|
|
},
|
|
{ type: 'sse', name: 'events', url: 'https://example.com/sse', headers: [] },
|
|
];
|
|
expect(acpMcpServersToConfigRecord(servers)).toEqual({
|
|
web: {
|
|
transport: 'http',
|
|
url: 'https://example.com/mcp',
|
|
headers: { Authorization: 'Bearer x' },
|
|
},
|
|
events: { transport: 'sse', url: 'https://example.com/sse', headers: undefined },
|
|
});
|
|
});
|
|
|
|
it('drops the unstable acp transport and returns undefined when nothing survives', () => {
|
|
const servers = [{ type: 'acp', name: 'nested', serverId: 'srv-1' } as unknown as McpServer];
|
|
expect(acpMcpServersToConfigRecord(servers)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('acpBlocksToContentParts', () => {
|
|
it('projects file links and embedded text resources with provenance', () => {
|
|
const parts = acpBlocksToContentParts([
|
|
{ type: 'resource_link', uri: 'file:///tmp/example.ts#L2-L4', name: 'example.ts' },
|
|
{ type: 'resource_link', uri: 'https://example.test/doc', name: 'remote doc' },
|
|
{
|
|
type: 'resource',
|
|
resource: { uri: 'memory://note/1', text: 'remember this' },
|
|
},
|
|
] as never);
|
|
|
|
expect(parts).toEqual([
|
|
{ type: 'text', text: '/tmp/example.ts:2-4' },
|
|
{
|
|
type: 'text',
|
|
text: '<resource_link uri="https://example.test/doc" name="remote doc" />',
|
|
},
|
|
{ type: 'text', text: '<resource uri="memory://note/1">remember this</resource>' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('compressPromptImageParts', () => {
|
|
const trash: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(trash.splice(0).map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })));
|
|
});
|
|
|
|
async function tempOriginalsDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
|
|
trash.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function imagePart(url: string): ContentPart {
|
|
return { type: 'image_url', imageUrl: { url } };
|
|
}
|
|
|
|
it('drops an unsupported image format and stands a text notice in', async () => {
|
|
// Zero bytes carry no magic number, so the declared MIME wins the
|
|
// effective-MIME resolution and the gate rejects it.
|
|
const heic = `data:image/heic;base64,${Buffer.alloc(32).toString('base64')}`;
|
|
const out = await compressPromptImageParts([{ type: 'text', text: 'look' }, imagePart(heic)]);
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0]).toEqual({ type: 'text', text: 'look' });
|
|
const notice = out[1];
|
|
expect(notice?.type).toBe('text');
|
|
expect((notice as { text: string }).text).toContain('unsupported image format image/heic');
|
|
});
|
|
|
|
it('rewrites accepted MIME aliases to the canonical form', async () => {
|
|
const base64 = solidPngBase64(8, 8);
|
|
const out = await compressPromptImageParts([imagePart(`data:IMAGE/PNG;base64,${base64}`)]);
|
|
expect(out).toHaveLength(1);
|
|
const part = out[0];
|
|
expect(part?.type).toBe('image_url');
|
|
expect((part as { imageUrl: { url: string } }).imageUrl.url).toBe(
|
|
`data:image/png;base64,${base64}`,
|
|
);
|
|
});
|
|
|
|
it('passes an under-limit image through unchanged and persists nothing', async () => {
|
|
const originalsDir = await tempOriginalsDir();
|
|
const url = `data:image/png;base64,${solidPngBase64(32, 32)}`;
|
|
const out = await compressPromptImageParts([imagePart(url)], {
|
|
originalsDir,
|
|
maxImageEdgePx: 64,
|
|
});
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toEqual(imagePart(url));
|
|
// No compression happened, so no original was persisted.
|
|
expect(await readdir(originalsDir)).toEqual([]);
|
|
});
|
|
|
|
it('compresses an over-edge image, prefixes a caption, and persists the original', async () => {
|
|
const originalsDir = await tempOriginalsDir();
|
|
const original = solidPng(128, 128);
|
|
const url = `data:image/png;base64,${original.toString('base64')}`;
|
|
const out = await compressPromptImageParts([imagePart(url)], {
|
|
originalsDir,
|
|
maxImageEdgePx: 64,
|
|
});
|
|
|
|
// caption text part immediately precedes the re-encoded image part.
|
|
expect(out).toHaveLength(2);
|
|
const caption = out[0] as { text: string };
|
|
const image = out[1] as { imageUrl: { url: string } };
|
|
expect(caption.text).toContain('Image compressed to fit model limits');
|
|
expect(caption.text).toContain('128x128');
|
|
expect(caption.text).toContain(originalsDir);
|
|
expect(image.imageUrl.url).not.toBe(url);
|
|
expect(image.imageUrl.url.startsWith('data:image/')).toBe(true);
|
|
|
|
// The original bytes landed in the session media-originals dir so the
|
|
// model can read fine detail back from the captioned path.
|
|
const files = await readdir(originalsDir);
|
|
expect(files).toHaveLength(1);
|
|
expect(caption.text).toContain(files[0]!);
|
|
expect(await readFile(join(originalsDir, files[0]!))).toEqual(original);
|
|
});
|
|
});
|