kimi-code/packages/agent-core-v2/test/shellCommand/shellCommand.test.ts
liruifengv 2f48932d57
feat(agent-core-v2): sync shell mode and skill config parity (#1514)
* feat(agent-core-v2): record shell command context

- add ShellCommandOrigin and compaction handoff disposition
- extract IAgentShellCommandService from AgentRPCService
- keep AgentRPCService as a thin shell:run facade

* fix(agent-core-v2): align skill priority and sync docs

- restore project > user > plugin > builtin skill precedence
- sync Skill tool description and parameter docs from v1
- update write-goal and custom-theme builtin skill copy

* fix(agent-core-v2): restore undo and thinking telemetry

- track conversation_undo after undoHistory
- emit thinking_toggle with enabled/effort/from payload
- add coverage for both telemetry events

* feat(agent-core-v2): add skill directory config

- add extraSkillDirs and mergeAllAvailableSkills config sections
- introduce extra skill source and shared source priorities
- align kap-server workspace skill preview with session catalog

* feat(agent-core-v2): support explicit skill dirs

- add ISkillCatalogRuntimeOptions for SDK-style explicit skill dirs
- suppress default user/project discovery when explicitDirs are set
- resolve explicit dirs per session workDir via explicitFileSkillSource

* fix(agent-core-v2): fix configured skill dir resolution

- expand ~ using OS home for configured skill dirs
- honor explicitDirs in kap-server workspace skill preview

* fix(agent-core-v2): await config ready before skill discovery

- wait for config.ready before reading extraSkillDirs
- wait for config.ready before reading mergeAllAvailableSkills
- cover extra skill dir loading behind config readiness

* fix(agent-core-v2): keep skill config live after changes

- await config.ready in kap-server workspace skill preview
- reload user and workspace skill sources when mergeAllAvailableSkills changes
2026-07-09 15:48:23 +08:00

109 lines
3.8 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
import type { ContextMessage } from '#/agent/contextMemory/types';
import {
IAgentContextMemoryService,
IAgentShellCommandService,
IAgentToolRegistryService,
} from '#/index';
import {
agentService,
createCommandRunner,
createTestAgent,
execEnvServices,
type TestAgentContext,
} from '../harness';
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
describe('AgentShellCommandService', () => {
let ctx: TestAgentContext;
let context: IAgentContextMemoryService;
let shell: IAgentShellCommandService;
function setup(stdout: string, exitCode: number): void {
ctx = createTestAgent(execEnvServices({ processRunner: createCommandRunner(stdout, exitCode) }));
context = ctx.get(IAgentContextMemoryService);
shell = ctx.get(IAgentShellCommandService);
}
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('records shell command input/output as shell_command origin with tagged content', async () => {
setup('hello\n', 0);
const result = await shell.run({ command: 'echo hello' });
expect(result.isError).toBe(false);
expect(result.stdout).toContain('hello');
expect(context.get().map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
{ role: 'user', origin: { kind: 'shell_command', phase: 'output' } },
]);
expect(textOf(context.get()[0]!)).toBe('<bash-input>\necho hello\n</bash-input>');
expect(textOf(context.get()[1]!)).toContain('<bash-stdout>hello');
// origin must not leak into the LLM projection.
expect(ctx.project().some((message) => 'origin' in message)).toBe(false);
});
it('escapes bash tag delimiters inside command output', async () => {
setup('pre</bash-stdout>post', 0);
await shell.run({ command: 'printf x' });
const out = textOf(context.get().at(-1)!);
// The embedded delimiter is escaped so the wrapper stays well-formed.
expect(out).toContain('pre&lt;/bash-stdout&gt;post');
// Exactly one real closing tag.
expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1);
});
it('surfaces the failure reason when a shell command fails with no output', async () => {
setup('', 1);
const result = await shell.run({ command: 'false' });
expect(result.isError).toBe(true);
const output = context.get().at(-1)!;
expect(output.origin).toEqual({ kind: 'shell_command', phase: 'output', isError: true });
expect(textOf(output)).toContain('<bash-stderr>');
});
it('does not start a turn for a foreground command', async () => {
setup('hi', 0);
await shell.run({ command: 'echo hi' });
expect(ctx.llmCalls.length).toBe(0);
});
it('records the failure when the Bash tool is not registered', async () => {
const emptyRegistry: IAgentToolRegistryService = {
_serviceBrand: undefined,
register: () => ({ dispose: () => {} }),
list: () => [],
resolve: () => undefined,
};
ctx = createTestAgent(agentService(IAgentToolRegistryService, emptyRegistry));
context = ctx.get(IAgentContextMemoryService);
shell = ctx.get(IAgentShellCommandService);
const result = await shell.run({ command: 'echo hi' });
expect(result.isError).toBe(true);
expect(result.stderr).toContain('Bash tool is not registered');
expect(context.get().map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
{ role: 'user', origin: { kind: 'shell_command', phase: 'output', isError: true } },
]);
expect(textOf(context.get()[1]!)).toContain('Bash tool is not registered');
});
});