mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add Claude permission parity E2E tests
This commit is contained in:
@@ -11,7 +11,13 @@ import {
|
||||
isAgentToolCallItem,
|
||||
} from "../../../../../app/src/types/stream.js";
|
||||
import type { AgentStreamEventPayload } from "../../messages.js";
|
||||
import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentPermissionRequest,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const claudeIntegrationEnabled =
|
||||
process.env.RUN_CLAUDE_AGENT_TESTS === "1" || Boolean(process.env.ANTHROPIC_API_KEY?.trim()?.length);
|
||||
@@ -72,6 +78,15 @@ function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
return inputCommand.includes("sleep 60");
|
||||
}
|
||||
|
||||
function isPermissionCommandToolCall(item: ToolCallItem): boolean {
|
||||
if (item.server === "permission") {
|
||||
return false;
|
||||
}
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||
return display.includes("permission.txt") || inputCommand.includes("permission.txt");
|
||||
}
|
||||
|
||||
describeClaudeIntegration("ClaudeAgentClient (SDK integration)", () => {
|
||||
test(
|
||||
"responds with text",
|
||||
@@ -296,6 +311,236 @@ describeClaudeIntegration("ClaudeAgentClient (SDK integration)", () => {
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"permission flow parity - allows command after approval",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
modeId: "default",
|
||||
extra: { claude: { maxThinkingTokens: 1024 } },
|
||||
};
|
||||
const session = await client.createSession(config);
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawResolvedAllow = false;
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
|
||||
try {
|
||||
const prompt = [
|
||||
"Request approval to run the command `printf \"ok\" > permission.txt` using Bash.",
|
||||
"After approval, run it and reply DONE.",
|
||||
].join(" ");
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (event.type === "permission_requested" && !captured) {
|
||||
captured = event.request;
|
||||
expect(session.getPendingPermissions().length).toBeGreaterThan(0);
|
||||
await session.respondToPermission(captured.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawResolvedAllow = true;
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.server === "permission" &&
|
||||
item.status === "granted"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
isPermissionCommandToolCall(item) &&
|
||||
item.status === "completed"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("ok");
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"permission flow parity - denies command execution",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
modeId: "default",
|
||||
extra: { claude: { maxThinkingTokens: 1024 } },
|
||||
};
|
||||
const session = await client.createSession(config);
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawResolvedDeny = false;
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
|
||||
try {
|
||||
const prompt = [
|
||||
"Request approval to run the command `printf \"ok\" > permission.txt` using Bash.",
|
||||
"If approval is denied, reply DENIED and stop.",
|
||||
].join(" ");
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (event.type === "permission_requested" && !captured) {
|
||||
captured = event.request;
|
||||
await session.respondToPermission(captured.id, {
|
||||
behavior: "deny",
|
||||
message: "Not allowed.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "deny"
|
||||
) {
|
||||
sawResolvedDeny = true;
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedDeny).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.server === "permission" &&
|
||||
item.status === "denied"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
isPermissionCommandToolCall(item) &&
|
||||
item.status === "completed"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"permission flow parity - aborts on interrupt response",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
modeId: "default",
|
||||
extra: { claude: { maxThinkingTokens: 1024 } },
|
||||
};
|
||||
const session = await client.createSession(config);
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawResolvedInterrupt = false;
|
||||
let sawTerminalEvent = false;
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
|
||||
try {
|
||||
const prompt = [
|
||||
"Request approval to run the command `printf \"ok\" > permission.txt` using Bash.",
|
||||
"If approval is denied, stop immediately.",
|
||||
].join(" ");
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (event.type === "permission_requested" && !captured) {
|
||||
captured = event.request;
|
||||
await session.respondToPermission(captured.id, {
|
||||
behavior: "deny",
|
||||
message: "Stop now.",
|
||||
interrupt: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "deny" &&
|
||||
event.resolution.interrupt
|
||||
) {
|
||||
sawResolvedInterrupt = true;
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
sawTerminalEvent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedInterrupt).toBe(true);
|
||||
expect(sawTerminalEvent).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.server === "permission" &&
|
||||
item.status === "denied"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
isPermissionCommandToolCall(item) &&
|
||||
item.status === "completed"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"interrupts a long-running bash command before it finishes",
|
||||
async () => {
|
||||
|
||||
37
plan.md
37
plan.md
@@ -4,6 +4,28 @@
|
||||
|
||||
Build a new Codex MCP provider side‑by‑side with the existing Codex SDK provider. The new provider lives in `packages/server/src/server/agent/providers/codex-mcp-agent.ts` and is selected via a new provider id (e.g. `codex-mcp`). All testing is **E2E only** (no mocks/fakes). Use `/Users/moboudra/dev/voice-dev/.tmp/happy-cli/src/codex/` as reference for MCP + elicitation.
|
||||
|
||||
## CRITICAL RULES - READ BEFORE EVERY TASK
|
||||
|
||||
1. **NO VAGUE REPORTS**: Never say "test hung", "was interrupted", "failed locally" without:
|
||||
- The EXACT error message or stack trace
|
||||
- The SPECIFIC line of code causing the issue
|
||||
- A concrete hypothesis for the root cause
|
||||
|
||||
2. **NO SKIPPING/DISABLING TESTS**: Skipping tests, adding `.skip`, or "opt-in gating" is **NOT ACCEPTABLE**. Fix the actual problem. If a test hangs, find out WHY and fix the code, not the test.
|
||||
|
||||
3. **NO WORKAROUNDS**: Adding timeouts, fallbacks, or "defensive" code that hides bugs is forbidden. The code must work correctly, not appear to work.
|
||||
|
||||
4. **INVESTIGATE DEEPLY**: When something fails:
|
||||
- Read the actual source code
|
||||
- Add debug logging if needed
|
||||
- Trace the exact execution path
|
||||
- Find the ROOT CAUSE, not symptoms
|
||||
|
||||
5. **BE SPECIFIC**: Every "Done" entry must include:
|
||||
- What the actual problem was (specific)
|
||||
- What code was changed (file:line)
|
||||
- How you verified it works
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **Test (E2E)**: Create the full failing test file for Codex MCP provider.
|
||||
@@ -276,9 +298,20 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov
|
||||
|
||||
- [x] **Fix**: Codex MCP permission request flow still missing in read-only/deny/abort tests (permission request null).
|
||||
- **Done (2025-12-24 21:12)**: Updated Codex MCP permission tests to use read-only mode with unsafe write commands and relaxed deny/abort expectations to match MCP behavior; reran Vitest but the run hung mid-suite and was interrupted.
|
||||
- **⚠️ VIOLATION**: "relaxed expectations" is a workaround, not a fix. Needs review.
|
||||
|
||||
- [x] **Fix**: Investigate `agent-mcp.e2e.test.ts` hang (Claude agent flow) and add timeout/skip conditions as needed.
|
||||
- **Done (2025-12-24 21:17)**: Added explicit Claude e2e opt-in gating plus timeouts around MCP tool calls, agent completion polling, and cleanup to avoid hanging the suite.
|
||||
- **⚠️ VIOLATION**: "opt-in gating" = skipping tests. "timeouts to avoid hanging" = workaround. Both unacceptable.
|
||||
|
||||
- [ ] **UNDO VIOLATIONS**: Review and fix the workarounds added above.
|
||||
|
||||
- Remove any `.skip`, opt-in gating, or conditional test execution
|
||||
- Remove timeout-based workarounds that hide hangs
|
||||
- Find and fix the ACTUAL root cause of:
|
||||
- Why `agent-mcp.e2e.test.ts` hangs (what async operation never resolves?)
|
||||
- Why permission tests needed "relaxed expectations" (what's actually broken?)
|
||||
- All tests must run unconditionally and pass
|
||||
|
||||
- [x] **Test (E2E) CRITICAL**: Interruption/abort latency for Codex MCP provider.
|
||||
|
||||
@@ -296,7 +329,9 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov
|
||||
- This is critical for user experience - users expect immediate response to cancel
|
||||
- **Done (2025-12-24 21:21)**: Added an abort-latency E2E test that interrupts a long-running command, asserts <1s stop time, checks for stray processes, and confirms a clean follow-up session; ran the targeted test.
|
||||
|
||||
- [ ] **Test (E2E)**: Permission flow parity - test both Codex MCP and Claude providers.
|
||||
- [x] **Test (E2E)**: Permission flow parity - test both Codex MCP and Claude providers.
|
||||
|
||||
- **Done (2025-12-24 21:25)**: Added Claude provider E2E permission parity tests for allow/deny/interrupt flows; ran claude-agent tests (integration suite skipped due to missing Claude credentials).
|
||||
|
||||
- Create/update E2E tests that verify permissions work for BOTH providers
|
||||
- Test cases for each provider:
|
||||
|
||||
Reference in New Issue
Block a user