fix(kap-server): accept question ids containing colons on resolve (#2585)
Some checks are pending
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(kap-server): accept question ids containing colons on resolve

Some OpenAI-compatible providers emit tool_call ids like
`AskUserQuestion:0`, which the question service adopts as the question
id. The action-suffix parse then rejected the bare resolve POST as an
unsupported action (40001), so clients could never submit answers.

When the suffix parse fails, fall back to matching the full tail
against the pending question list before emitting 40001. Also add
maxRetries to the test home cleanup to absorb the async query-store
shard flush (ENOTEMPTY on macOS), matching fs.test.ts.

* fix(kap-server): preserve 40902 on duplicate resolve of colon-id questions

A retried bare resolve of a colon-bearing question id re-entered the
invalid-suffix fallback after the question settled, found no pending
match, and returned 40001 — bypassing the recently-resolved idempotency
window. Accept the tail in the fallback when it is recently resolved so
the shared duplicate-resolve path emits 40902 as documented.
This commit is contained in:
Haozhe 2026-08-04 12:58:51 +08:00 committed by GitHub
parent f412e105b3
commit c39687318c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 7 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kap-server": patch
"@moonshot-ai/kimi-code": patch
---
Fix submitting answers to interactive question prompts being rejected when the model provider returns tool call IDs containing colons (some OpenAI-compatible gateways).

View file

@ -18,6 +18,15 @@
* Fastify cannot disambiguate `:question_id` from `:question_id:dismiss` on the
* same path prefix. The POST body is therefore validated manually (the dismiss
* path carries an empty body), not via the Zod preHandler.
*
* **Colon-bearing question ids**: the question id is derived from the LLM
* tool_call id, and some providers emit ids containing a colon (e.g.
* `AskUserQuestion:0`), which the action-suffix parse rejects as an unknown
* action. The resolve handler therefore falls back to matching the FULL tail
* against the pending list before emitting 40001 a hit resolves, a miss
* keeps the validation error. (Dismiss is unaffected: `id:0:dismiss` parses
* off the final colon.)
*
* Error mapping (REST.md §3.6):
* - 40401 (session.not_found) no live session matches {sid}
@ -164,12 +173,6 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
defaultAction: 'resolve',
resourceLabel: 'question',
});
if (parsed.kind === 'invalid') {
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id));
return;
}
const questionId = parsed.id;
const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action;
const handle = await resumeSessionById(core.accessor, session_id);
if (handle === undefined) {
@ -180,6 +183,30 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
}
const interaction = handle.accessor.get(ISessionInteractionService);
let questionId: string;
let action: 'resolve' | 'dismiss';
if (parsed.kind === 'invalid') {
// Compat fallback: some providers emit tool_call ids CONTAINING a
// colon (e.g. `AskUserQuestion:0`), which the action-suffix parse
// rejects as an unknown action. Treat the full tail as the question
// id when it matches a pending (or recently-resolved, so duplicate
// resolves keep the 40902 semantics) question; otherwise keep 40001.
if (
interaction.listPending('question').some((i) => i.id === tail) ||
interaction.isRecentlyResolved(tail)
) {
questionId = tail;
action = 'resolve';
} else {
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id));
return;
}
} else {
questionId = parsed.id;
action = parsed.kind === 'bare' ? 'resolve' : parsed.action;
}
const pendingInteraction = interaction
.listPending('question')
.find((i) => i.id === questionId);

View file

@ -86,7 +86,9 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
// maxRetries: the async query-store shard writer can still be flushing
// after close (ENOTEMPTY on macOS) — same retry pattern as fs.test.ts.
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
home = undefined;
}
});
@ -334,6 +336,58 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
expect(body.code).toBe(40405);
});
it('resolves a question whose id contains a colon (provider tool_call id)', async () => {
const sid = await createSession();
// Real-world shape: no explicit id, so the question id falls back to the
// tool_call id — which some providers emit as `{function_name}:{index}`.
const resultPromise: Promise<QuestionResult> = questionService(sid).request({
toolCallId: 'AskUserQuestion:0',
questions: [
{
question: 'Pick one',
options: [{ label: 'Yes' }, { label: 'No' }],
},
],
});
const list = await getJson<ListWire>(`/api/v1/sessions/${sid}/questions?status=pending`);
expect(list.body.data.items[0]!.question_id).toBe('AskUserQuestion:0');
const { body } = await postJson<ResolveWire>(
`/api/v1/sessions/${sid}/questions/AskUserQuestion%3A0`,
{ answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } },
);
expect(body.code).toBe(0);
expect(body.data.resolved).toBe(true);
await expect(resultPromise).resolves.toEqual({ answers: { 'Pick one': 'Yes' } });
});
it('keeps 40001 for a colon tail that matches no pending question', async () => {
const sid = await createSession();
const { body } = await postJson<null>(`/api/v1/sessions/${sid}/questions/q-9:0`, {
answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
});
expect(body.code).toBe(40001);
});
it('returns 40902 on a duplicate resolve of a colon-id question', async () => {
const sid = await createSession();
questionService(sid).enqueue({
toolCallId: 'AskUserQuestion:1',
questions: [{ question: 'Pick one', options: [{ label: 'Yes' }] }],
});
const url = `/api/v1/sessions/${sid}/questions/AskUserQuestion%3A1`;
await postJson<ResolveWire>(url, {
answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
});
const dup = await postJson<{ resolved: false }>(url, {
answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
});
expect(dup.body.code).toBe(40902);
expect(dup.body.data).toEqual({ resolved: false });
});
it('returns 40401 for an unknown session', async () => {
const { body } = await getJson<null>('/api/v1/sessions/nope/questions?status=pending');
expect(body.code).toBe(40401);