Compare commits

..
4 changed files with 34 additions and 267 deletions
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { TerminalInputModeTracker } from "./terminal-input-mode";
import { encodeTerminalKeyInput } from "./terminal-key-input";
describe("Modified Enter negotiation", () => {
it("answers a keyboard query split immediately after ESC", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b");
expect(tracker.feed("[?u").responses).toEqual(["\x1b[?0u"]);
});
it("recognizes modified Enter after a program enables modifyOtherKeys level 2", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[>4;2m");
expect(tracker.supportsModifiedEnter()).toBe(true);
});
it("distinguishes Shift+Enter from Enter after modifyOtherKeys negotiation", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[>4;2m");
const options = { inputMode: tracker.getState() };
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[27;2;13~");
expect(encodeTerminalKeyInput({ key: "Enter" }, options)).toBe("\r");
});
});
@@ -203,26 +203,24 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada
};
}
/**
* The answers this response will actually deliver, keyed by full question text,
* or null when there is no question list and answers map to work from.
*
* Shared by the normalizer and by the check that rejects an undeliverable
* answer, so the two can never disagree about what "deliverable" means.
*/
function resolveClaudeAskUserQuestionAnswers(
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): Record<string, string> | null {
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
const questions =
(Array.isArray(base.questions) ? base.questions : null) ??
(Array.isArray(fallback.questions) ? fallback.questions : null);
const answers = isMetadata(base.answers) ? base.answers : null;
if (!questions || !answers) {
return null;
return merged;
}
const normalizedAnswers: Record<string, string> = {};
@@ -246,23 +244,7 @@ function resolveClaudeAskUserQuestionAnswers(
}
}
return normalizedAnswers;
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput);
if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) {
if (Object.keys(normalizedAnswers).length === 0) {
return merged;
}
@@ -272,32 +254,6 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
};
}
class ClaudeQuestionAnswerRequiredError extends Error {
readonly code = "question_answer_required" as const;
constructor(readonly requestId: string) {
super(
`Permission request '${requestId}' requires updatedInput.answers keyed by a ` +
`question's full text or header, with a non-empty string value. ` +
`The request is still pending; retry with that shape.`,
);
this.name = "ClaudeQuestionAnswerRequiredError";
}
}
function assertClaudeQuestionAnswerDeliverable(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): void {
if (request.kind !== "question" || response.behavior !== "allow") {
return;
}
const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input);
if (!answers || Object.keys(answers).length === 0) {
throw new ClaudeQuestionAnswerRequiredError(request.id);
}
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -2778,12 +2734,6 @@ class ClaudeAgentSession implements AgentSession {
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
// Before the delete below, so a rejected answer leaves the request pending
// and answerable rather than consuming it for nothing. AgentManager only
// drops its own copy once this resolves, so both maps survive the throw.
assertClaudeQuestionAnswerDeliverable(pending.request, response);
this.pendingPermissions.delete(requestId);
pending.cleanup?.();
@@ -1,164 +0,0 @@
import { describe, expect, test } from "vitest";
import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
import type { AgentPermissionResponse } from "../../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
// The SDK dependency receives the same permission callback as a real Claude process.
// Keep its stream open until close; permission state belongs entirely to the session.
function createSdkFake() {
let canUseTool: CanUseTool | undefined;
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const queryFactory: ClaudeQueryFactory = ({ options }) => {
canUseTool = options.canUseTool;
return {
close: finish,
return: async () => {
finish();
return { value: undefined, done: true };
},
[Symbol.asyncIterator]() {
return {
next: async (): Promise<IteratorResult<SDKMessage, void>> => {
await finished;
return { value: undefined, done: true };
},
};
},
} as Query;
};
return {
queryFactory,
ask: (input: Record<string, unknown>) => {
if (!canUseTool) throw new Error("SDK permission callback was not configured");
return canUseTool("AskUserQuestion", input, {
signal: new AbortController().signal,
toolUseID: "question-tool-call",
});
},
};
}
const questionInput = {
questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }],
};
const invalidResponses: Array<[string, AgentPermissionResponse]> = [
["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }],
["missing answers", { behavior: "allow", updatedInput: {} }],
["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }],
["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }],
["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }],
[
"empty echoed questions",
{ behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } },
],
];
describe("Claude question permission responses", () => {
test.each(invalidResponses)(
"rejects %s and delivers a corrected retry",
async (_label, response) => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(questionInput);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
expect(request.kind).toBe("question");
await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({
code: "question_answer_required",
requestId: request.id,
});
expect(session.getPendingPermissions()).toEqual([request]);
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: { answers: { Colour: "Viridian" } },
});
await expect(answer).resolves.toMatchObject({
behavior: "allow",
updatedInput: { answers: { "Which colour?": "Viridian" } },
});
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
},
);
test.each([
["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }],
["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }],
[
"padded question text",
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
{ " Which colour? ": "Ochre" },
{ " Which colour? ": "Ochre" },
],
[
"padded header",
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
{ " Colour ": "Viridian" },
{ " Which colour? ": "Viridian" },
],
[
"partial answer",
{ questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] },
{ Ship: "Yes" },
{ "Ship it?": "Yes" },
],
])("delivers answers keyed by %s", async (_label, input, answers, expected) => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(input);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: { answers },
});
await expect(answer).resolves.toMatchObject({
behavior: "allow",
updatedInput: { answers: expected },
});
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test("allows denying a question without answers", async () => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(questionInput);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" });
await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
@@ -169,47 +169,3 @@ test("a restored public SDK timeline leaves missed history to the consumer", asy
await daemon.close();
}
});
test("active directory observers agree on creation and workspace archive across clients", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const actor = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
const observer = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
const directory = new Set<string>();
try {
await actor.connect();
await observer.connect();
const subscription = observer.observeAgents({
scope: "active",
sync: {},
includeBackground: true,
});
subscription.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type !== "agent_update") return;
if (message.payload.kind === "upsert") directory.add(message.payload.agent.id);
else directory.delete(message.payload.agentId);
},
});
await subscription.ready;
const agent = await actor.createAgent({
provider: "codex",
cwd: daemon.staticDir,
title: "Cross-client agent",
});
await expect.poll(() => directory.has(agent.id)).toBe(true);
const beforeArchive = await observer.fetchAgents({ scope: "active", sync: {} });
expect(beforeArchive.entries.map((entry) => entry.agent.id)).toContain(agent.id);
await actor.archiveWorkspace(agent.workspaceId!);
const afterArchive = await observer.fetchAgents({ scope: "active", sync: {} });
expect(afterArchive.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
await expect.poll(() => directory.has(agent.id)).toBe(false);
} finally {
await actor.close();
await observer.close();
await daemon.close();
}
});