fix(kap-server): route prompt submits by agent_id

`resolveLegacy` always resolved the main agent, ignoring `agent_id` in the
prompt submission body. A `/btw <question>` submit carries the forked
side-channel child's `agent_id`, so it was answered in the main view instead
of the right-side BTW panel.

Resolve the agent named by `agent_id` (falling back to `main`), and return
40401 for an unknown agent id. Add regression tests for side-channel routing
and the unknown-agent case.
This commit is contained in:
haozhe.yang 2026-07-07 21:42:14 +08:00
parent 7b041a8e03
commit e6b90d95f5
2 changed files with 73 additions and 4 deletions

View file

@ -6,6 +6,7 @@
*/
import {
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionLifecycleService,
isKimiError,
@ -25,7 +26,7 @@ import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { ensureMainAgent } from '../transport/mainAgent';
import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent';
import { parseActionSuffix } from './action-suffix';
interface PromptRouteHost {
@ -55,7 +56,11 @@ const validationDetailsSchema = z.array(z.object({ path: z.string(), message: z.
const authProviderDetailsSchema = z.object({ provider_id: z.string() });
const authModelDetailsSchema = z.object({ model_id: z.string(), provider_id: z.string() }).partial();
async function resolveLegacy(core: Scope, sessionId: string): Promise<IAgentPromptLegacyService> {
async function resolveLegacy(
core: Scope,
sessionId: string,
agentId?: string,
): Promise<IAgentPromptLegacyService> {
// `resume` (not `get`) so a persisted-but-cold session — created by a previous
// process, by v1, or closed in this one — is loaded from disk instead of
// being reported as `session.not_found`. Mirrors the snapshot route. Returns
@ -64,7 +69,17 @@ async function resolveLegacy(core: Scope, sessionId: string): Promise<IAgentProm
if (session === undefined) {
throw new KimiError('session.not_found', `session ${sessionId} does not exist`);
}
const agent = await ensureMainAgent(session);
// A prompt may target a forked side-channel agent (e.g. `/btw`) via
// `body.agent_id`. Default to `main` when absent; only `main` is
// auto-created — any other id must already exist (forked beforehand), or it
// is reported as `agent.not_found`.
const agent =
agentId === undefined || agentId === MAIN_AGENT_ID
? await ensureMainAgent(session)
: session.accessor.get(IAgentLifecycleService).getHandle(agentId);
if (agent === undefined) {
throw new KimiError('agent.not_found', `agent ${agentId} does not exist`);
}
return agent.accessor.get(IAgentPromptLegacyService);
}
@ -116,7 +131,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
async (req, reply) => {
try {
const { session_id } = req.params;
const legacy = await resolveLegacy(core, session_id);
const legacy = await resolveLegacy(core, session_id, req.body.agent_id);
const result = await legacy.submit(req.body);
reply.send(okEnvelope(result, req.id));
} catch (error) {

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
IAgentContextMemoryService,
IAgentLifecycleService,
ISessionLifecycleService,
} from '@moonshot-ai/agent-core-v2';
@ -179,4 +180,57 @@ describe('server-v2 /api/v1 prompts', () => {
expect(list.body.data.active).toBeNull();
expect(list.body.data.queued).toEqual([]);
});
it('routes a submitted prompt to the agent named by agent_id (BTW side channel)', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
// Fork the main agent into a side-channel child the way `/btw` does.
const session = server!.core.accessor.get(ISessionLifecycleService).get(id);
if (session === undefined) throw new Error(`session ${id} not found`);
const lifecycle = session.accessor.get(IAgentLifecycleService);
const child = await lifecycle.fork('main');
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
content: [{ type: 'text', text: 'side question' }],
agent_id: child.id,
});
expect(submitted.body.code).toBe(0);
// The user message is appended to the target agent's context before the turn
// runs, so it persists even after the (model-less) turn settles — a durable
// signal of which agent actually received the prompt.
const contextHasUserText = (
handle: { accessor: { get: typeof child.accessor.get } },
text: string,
): boolean =>
handle.accessor
.get(IAgentContextMemoryService)
.get()
.some(
(m) =>
m.role === 'user' &&
m.content.some((p) => p.type === 'text' && p.text === text),
);
// The side-channel child received the prompt.
expect(contextHasUserText(child, 'side question')).toBe(true);
// The main agent must NOT have received it — previously the route ignored
// agent_id and always targeted main, so the reply landed in the main view.
const main = lifecycle.getHandle('main');
expect(main).toBeDefined();
expect(contextHasUserText(main!, 'side question')).toBe(false);
});
it('returns 40401 when agent_id names an unknown agent', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
const { body } = await call<null>('POST', `/api/v1/sessions/${id}/prompts`, {
content: [{ type: 'text', text: 'hello' }],
agent_id: 'agent_does_not_exist',
});
expect(body.code).toBe(40401);
});
});