feat: wait returns on permission request and improve CLI logging

This commit is contained in:
Mohamed Boudra
2026-01-29 12:12:39 +07:00
parent c015fdbef1
commit 7a8841481e
13 changed files with 295 additions and 341 deletions
+1 -7
View File
@@ -84,14 +84,8 @@ export function createCli(): Command {
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--json', 'Output in JSON format (only when not following)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((id, options, command) => {
if (options.json && !options.follow) {
command.parent.opts().format = 'json'
}
return withOutput(runLogsCommand)(id, options, command)
})
.action(runLogsCommand)
program
.command('stop')
+2 -1
View File
@@ -64,8 +64,9 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLogsCommand))
.action(runLogsCommand)
agent
.command('stop')
+78 -76
View File
@@ -3,37 +3,39 @@ import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Agent inspect data for display */
/** Agent inspect data for display (matches CLI spec format) */
interface AgentInspect {
id: string
name: string
provider: string
model: string
status: string
mode: string
cwd: string
createdAt: string
archivedAt: string | null
lastUsage: {
inputTokens: number
outputTokens: number
cachedInputTokens: number
totalCostUsd: number
Id: string
Name: string
Provider: string
Model: string
Status: string
Mode: string
Cwd: string
CreatedAt: string
UpdatedAt: string
LastUsage: {
InputTokens: number
OutputTokens: number
CachedTokens: number
CostUsd: number
} | null
capabilities: {
streaming: boolean
persistence: boolean
dynamicModes: boolean
mcpServers: boolean
Capabilities: {
Streaming: boolean
Persistence: boolean
DynamicModes: boolean
McpServers: boolean
} | null
availableModes: Array<{
AvailableModes: Array<{
id: string
label: string
}> | null
pendingPermissions: Array<{
PendingPermissions: Array<{
id: string
tool: string
}>
Worktree: string | null
ParentAgentId: string | null
}
/** Key-value row for table display */
@@ -86,94 +88,94 @@ function formatCost(costUsd: number): string {
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
const lastUsage = snapshot.lastUsage
? {
inputTokens: snapshot.lastUsage.inputTokens ?? 0,
outputTokens: snapshot.lastUsage.outputTokens ?? 0,
cachedInputTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
totalCostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
}
: null
const capabilities = snapshot.capabilities
? {
streaming: snapshot.capabilities.supportsStreaming ?? false,
persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
dynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
mcpServers: snapshot.capabilities.supportsMcpServers ?? false,
Streaming: snapshot.capabilities.supportsStreaming ?? false,
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
}
: null
// Extract worktree and parentAgentId from labels if they exist
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
return {
id: snapshot.id,
name: snapshot.title ?? '-',
provider: snapshot.provider,
model: snapshot.model ?? '-',
status: snapshot.status,
mode: snapshot.currentModeId ?? 'default',
cwd: snapshot.cwd,
createdAt: snapshot.createdAt,
archivedAt: snapshot.archivedAt ?? null,
lastUsage,
capabilities,
availableModes: snapshot.availableModes
Id: snapshot.id,
Name: snapshot.title ?? '-',
Provider: snapshot.provider,
Model: snapshot.model ?? '-',
Status: snapshot.status,
Mode: snapshot.currentModeId ?? 'default',
Cwd: snapshot.cwd,
CreatedAt: snapshot.createdAt,
UpdatedAt: snapshot.updatedAt,
LastUsage: lastUsage,
Capabilities: capabilities,
AvailableModes: snapshot.availableModes
? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label }))
: null,
pendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
id: p.id,
tool: p.name ?? 'unknown',
})),
Worktree: worktree,
ParentAgentId: parentAgentId,
}
}
/** Convert agent to key-value rows for table display */
function toInspectRows(agent: AgentInspect): InspectRow[] {
const rows: InspectRow[] = [
{ key: 'Id', value: agent.id },
{ key: 'Name', value: agent.name },
{ key: 'Provider', value: agent.provider },
{ key: 'Model', value: agent.model },
{ key: 'Status', value: agent.status },
{ key: 'Mode', value: agent.mode },
{ key: 'Cwd', value: shortenPath(agent.cwd) },
{ key: 'CreatedAt', value: agent.createdAt },
{ key: 'Id', value: agent.Id },
{ key: 'Name', value: agent.Name },
{ key: 'Provider', value: agent.Provider },
{ key: 'Model', value: agent.Model },
{ key: 'Status', value: agent.Status },
{ key: 'Mode', value: agent.Mode },
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
{ key: 'CreatedAt', value: agent.CreatedAt },
{ key: 'UpdatedAt', value: agent.UpdatedAt },
]
if (agent.archivedAt) {
rows.push({ key: 'ArchivedAt', value: agent.archivedAt })
}
if (agent.lastUsage) {
if (agent.LastUsage) {
rows.push({
key: 'LastUsage',
value: `${agent.lastUsage.inputTokens} in, ${agent.lastUsage.outputTokens} out, ${formatCost(agent.lastUsage.totalCostUsd)}`,
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
})
}
if (agent.capabilities) {
const caps = agent.capabilities
const capsList = [
caps.streaming ? 'Streaming' : null,
caps.persistence ? 'Persistence' : null,
caps.dynamicModes ? 'DynamicModes' : null,
caps.mcpServers ? 'McpServers' : null,
].filter(Boolean)
rows.push({ key: 'Capabilities', value: capsList.join(', ') || 'none' })
if (agent.Capabilities) {
rows.push({
key: 'Capabilities',
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
})
}
if (agent.availableModes && agent.availableModes.length > 0) {
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
rows.push({
key: 'AvailableModes',
value: agent.availableModes.map((m) => m.id).join(', '),
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
})
}
if (agent.pendingPermissions.length > 0) {
rows.push({
key: 'PendingPermissions',
value: agent.pendingPermissions.length.toString(),
})
} else {
rows.push({ key: 'PendingPermissions', value: '[]' })
}
rows.push({
key: 'PendingPermissions',
value: agent.PendingPermissions.length > 0
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
: '[]'
})
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
return rows
}
+47 -221
View File
@@ -1,29 +1,13 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { CommandOptions } from '../../output/index.js'
import type {
DaemonClientV2,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentTimelineItem,
} from '@paseo/server'
/** Timeline item for display */
export interface LogEntry {
timestamp: string
type: string
summary: string
}
/** Schema for logs output */
export const logsSchema: OutputSchema<LogEntry> = {
idField: 'timestamp',
columns: [
{ header: 'TIME', field: 'timestamp', width: 12 },
{ header: 'TYPE', field: 'type', width: 15 },
{ header: 'SUMMARY', field: 'summary', width: 60 },
],
}
import { curateAgentActivity } from '@paseo/server'
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
@@ -32,74 +16,9 @@ export interface AgentLogsOptions extends CommandOptions {
since?: string
}
export type AgentLogsResult = ListResult<LogEntry>
// Logs command returns void - it outputs directly to console
export type AgentLogsResult = void
/** Format a timeline item into a log entry */
function formatTimelineItem(item: AgentTimelineItem): LogEntry {
const now = new Date().toISOString().slice(11, 19) // HH:MM:SS
switch (item.type) {
case 'user_message':
return {
timestamp: now,
type: 'user',
summary: truncate(item.text, 60),
}
case 'assistant_message':
return {
timestamp: now,
type: 'assistant',
summary: truncate(item.text, 60),
}
case 'reasoning':
return {
timestamp: now,
type: 'reasoning',
summary: truncate(item.text, 60),
}
case 'tool_call': {
const toolName = item.name
const status = item.status ?? ''
let inputSummary = ''
if (item.input && typeof item.input === 'object') {
const inp = item.input as Record<string, unknown>
// Common input fields for summarization
if (inp.command) {
inputSummary = truncate(String(inp.command), 40)
} else if (inp.file_path) {
inputSummary = truncate(String(inp.file_path), 40)
} else if (inp.pattern) {
inputSummary = truncate(String(inp.pattern), 40)
}
}
return {
timestamp: now,
type: `tool:${toolName}`,
summary: inputSummary ? `${status} ${inputSummary}`.trim() : status,
}
}
case 'todo': {
const completed = item.items.filter((i) => i.completed).length
return {
timestamp: now,
type: 'todo',
summary: `${completed}/${item.items.length} completed`,
}
}
case 'error':
return {
timestamp: now,
type: 'error',
summary: truncate(item.message, 60),
}
}
}
function truncate(str: string, maxLen: number): string {
const cleaned = str.replace(/\n/g, ' ').trim()
if (cleaned.length <= maxLen) return cleaned
return cleaned.slice(0, maxLen - 3) + '...'
}
/**
* Check if a timeline item matches the filter type
@@ -126,43 +45,6 @@ function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
}
}
/**
* Parse a timestamp string and return a Date object
* Supports ISO format and relative times like "5m", "1h", "2d"
*/
function parseTimestamp(timeStr: string): Date | null {
// Try ISO format first
const isoDate = new Date(timeStr)
if (!isNaN(isoDate.getTime())) {
return isoDate
}
// Try relative time format (e.g., "5m", "1h", "2d")
const match = timeStr.match(/^(\d+)([smhd])$/)
if (match) {
const value = parseInt(match[1], 10)
const unit = match[2]
const now = new Date()
switch (unit) {
case 's':
now.setSeconds(now.getSeconds() - value)
return now
case 'm':
now.setMinutes(now.getMinutes() - value)
return now
case 'h':
now.setHours(now.getHours() - value)
return now
case 'd':
now.setDate(now.getDate() - value)
return now
}
}
return null
}
/**
* Extract timeline items from an agent_stream_snapshot message
*/
@@ -194,12 +76,9 @@ export async function runLogsCommand(
const host = getDaemonHost({ host: options.host as string | undefined })
if (!id) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required',
details: 'Usage: paseo agent logs <id>',
}
throw error
console.error('Error: Agent ID required')
console.error('Usage: paseo agent logs <id>')
process.exit(1)
}
let client: DaemonClientV2
@@ -207,12 +86,9 @@ export async function runLogsCommand(
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
}
try {
@@ -226,22 +102,19 @@ export async function runLogsCommand(
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
}
// For follow mode, we stream events continuously
if (options.follow) {
return await runFollowMode(client, resolvedId, options)
await runFollowMode(client, resolvedId, options)
return
}
// For non-follow mode, initialize the agent to get timeline snapshot
const logEntries: LogEntry[] = []
// Set up handler for timeline events before initializing
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
@@ -265,7 +138,7 @@ export async function runLogsCommand(
}
// Get timeline from snapshot
const timelineItems = await snapshotPromise
let timelineItems = await snapshotPromise
// Also check message queue for any stream events
const queue = client.getMessageQueue()
@@ -281,64 +154,29 @@ export async function runLogsCommand(
}
}
// Parse since timestamp if provided
const sinceDate = options.since ? parseTimestamp(options.since) : null
if (options.since && !sinceDate) {
const error: CommandError = {
code: 'INVALID_TIMESTAMP',
message: `Invalid timestamp format: ${options.since}`,
details: 'Use ISO format (e.g., 2024-01-15T10:30:00) or relative time (e.g., 5m, 1h, 2d)',
}
throw error
// Apply filter
if (options.filter) {
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
}
// Convert to log entries with filtering
for (const item of timelineItems) {
// Apply filter
if (!matchesFilter(item, options.filter)) {
continue
// Apply tail limit
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
timelineItems = timelineItems.slice(-tailCount)
}
const entry = formatTimelineItem(item)
// Apply since filter (note: we're using current time for all entries, this is a limitation)
// In a real implementation, timeline items should have their own timestamps
if (sinceDate) {
// Since we don't have actual timestamps on timeline items, we can't filter by time
// This would need to be implemented with proper timestamp support in the timeline items
}
logEntries.push(entry)
}
await client.close()
// Apply tail limit
let entries = logEntries
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
entries = entries.slice(-tailCount)
}
}
return {
type: 'list',
data: entries,
schema: logsSchema,
}
// Use curateAgentActivity to format the transcript
const transcript = curateAgentActivity(timelineItems)
console.log(transcript)
} catch (err) {
await client.close().catch(() => {})
// Re-throw if already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'LOGS_FAILED',
message: `Failed to get logs: ${message}`,
}
throw error
console.error(`Error: Failed to get logs: ${message}`)
await client.close().catch(() => {})
process.exit(1)
}
}
@@ -349,9 +187,7 @@ async function runFollowMode(
client: DaemonClientV2,
agentId: string,
options: AgentLogsOptions
): Promise<AgentLogsResult> {
const logEntries: LogEntry[] = []
): Promise<void> {
// First, get existing timeline
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
@@ -375,24 +211,25 @@ async function runFollowMode(
}
// Get existing timeline
const existingItems = await snapshotPromise
let existingItems = await snapshotPromise
// Apply filter to existing items
let itemsToShow = existingItems.filter((item) => matchesFilter(item, options.filter))
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
}
// Apply tail to existing items
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
itemsToShow = itemsToShow.slice(-tailCount)
existingItems = existingItems.slice(-tailCount)
}
}
// Print existing entries
for (const item of itemsToShow) {
const entry = formatTimelineItem(item)
logEntries.push(entry)
printLogEntry(entry)
// Print existing transcript
const existingTranscript = curateAgentActivity(existingItems)
if (existingTranscript !== 'No activity to display.') {
console.log(existingTranscript)
}
// Subscribe to new events
@@ -406,12 +243,14 @@ async function runFollowMode(
if (message.payload.event.type === 'timeline') {
const item = message.payload.event.item
// Apply filter
if (!matchesFilter(item, options.filter)) {
if (options.filter && !matchesFilter(item, options.filter)) {
return
}
const entry = formatTimelineItem(item)
logEntries.push(entry)
printLogEntry(entry)
// Print each timeline item as it arrives using the curator format
const transcript = curateAgentActivity([item])
if (transcript !== 'No activity to display.') {
console.log(transcript)
}
}
})
@@ -427,17 +266,4 @@ async function runFollowMode(
})
await client.close()
return {
type: 'list',
data: logEntries,
schema: logsSchema,
}
}
function printLogEntry(entry: LogEntry): void {
// Simple format for streaming output
const typeWidth = 15
const paddedType = entry.type.padEnd(typeWidth)
console.log(`${entry.timestamp} ${paddedType} ${entry.summary}`)
}
+20 -3
View File
@@ -5,7 +5,7 @@ import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '.
/** Result type for agent wait command */
export interface AgentWaitResult {
agentId: string
status: 'idle' | 'timeout'
status: 'idle' | 'timeout' | 'permission'
message: string
}
@@ -140,12 +140,29 @@ export async function runWaitCommand(
throw error
}
// Wait for agent to become idle
// Wait for agent to become idle OR request permission (whichever comes first)
try {
await client.waitForAgentIdle(agentId, timeoutMs)
const idlePromise = client.waitForAgentIdle(agentId, timeoutMs).then(() => ({ type: 'idle' as const }))
const permissionPromise = client
.waitForPermission(agentId, timeoutMs)
.then((request) => ({ type: 'permission' as const, request }))
const result = await Promise.race([idlePromise, permissionPromise])
await client.close()
if (result.type === 'permission') {
return {
type: 'single',
data: {
agentId,
status: 'permission',
message: `Agent is waiting for permission: ${result.request.kind}`,
},
schema: agentWaitSchema,
}
}
return {
type: 'single',
data: {
+4 -10
View File
@@ -48,15 +48,7 @@ export async function runAllowCommand(
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host })
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Request ID is required unless --all is specified',
details: 'Usage: paseo permit allow <agent> <req_id> or paseo permit allow <agent> --all',
}
throw error
}
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined
@@ -129,7 +121,9 @@ export async function runAllowCommand(
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[]
if (options.all) {
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions
} else {
// Find permission by ID prefix
+3 -20
View File
@@ -8,10 +8,7 @@ export interface PermissionListItem {
id: string
agentId: string
agentShortId: string
agentName: string
name: string
kind: string
title: string
description: string
}
@@ -19,21 +16,10 @@ export interface PermissionListItem {
export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: 'id',
columns: [
{ header: 'REQUEST ID', field: 'id', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'NAME', field: 'agentName', width: 20 },
{ header: 'AGENT', field: 'agentShortId', width: 12 },
{ header: 'REQ_ID', field: 'id', width: 12 },
{ header: 'TOOL', field: 'name', width: 20 },
{
header: 'KIND',
field: 'kind',
width: 8,
color: (value) => {
if (value === 'tool') return 'blue'
if (value === 'plan') return 'magenta'
return undefined
},
},
{ header: 'TITLE', field: 'title', width: 30 },
{ header: 'DESCRIPTION', field: 'description', width: 50 },
],
}
@@ -43,10 +29,7 @@ function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequ
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
agentName: agent.title ?? '-',
name: permission.name,
kind: permission.kind,
title: permission.title ?? '-',
description: permission.description ?? '-',
}
}
@@ -1902,6 +1902,10 @@ export class DaemonClientV2 {
sawRunningInQueue = true;
queuedIdle = null; // Reset: any previous idle was before this run
}
// Return immediately if we have pending permissions (even if still running)
if (sawRunningInQueue && hasPendingPermissions) {
return msg.payload;
}
if (
sawRunningInQueue &&
(status === "idle" || status === "error") &&
@@ -1934,6 +1938,11 @@ export class DaemonClientV2 {
if (status === "running" || hasPendingPermissions) {
sawRunning = true;
}
// Return if we have pending permissions (even if still running)
// OR if agent is idle/error with no pending permissions (after having run)
if (sawRunning && hasPendingPermissions) {
return msg.payload;
}
if (
sawRunning &&
(status === "idle" || status === "error") &&
@@ -384,7 +384,8 @@ export class AgentManager {
);
}
return this.registerSession(session, overrides, agentId);
// Preserve existing labels during refresh
return this.registerSession(session, overrides, agentId, { labels: existing.labels });
}
async closeAgent(agentId: string): Promise<void> {
@@ -14,7 +14,7 @@ Core rules:
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- Set cwd to the repository root and choose the most permissive mode available.
- If an agent control call fails, list agents before launching another; it may just be a wait timeout.
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
Context management:
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
@@ -0,0 +1,123 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "wait-perm-e2e-"));
}
/**
* Tests for wait returning on permission request.
*
* The `paseo wait` command should return when:
* 1. Agent completes (goes idle)
* 2. Agent requests permission
*
* This test verifies that waitForAgentIdle correctly returns when
* an agent requests permission, not just when it goes idle.
*/
describe("wait returns on permission request", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("Claude provider", () => {
test(
"waitForAgentIdle returns when permission is requested (not just when idle)",
async () => {
const cwd = tmpCwd();
const testFilePath = path.join(cwd, "test-wait-perm.txt");
// Create Claude agent with default mode (always ask for permissions)
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Wait Permission Test",
modeId: "default",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that requires file write permission
const prompt = [
`You must use the Write tool to create a file at "${testFilePath}" with content "hello".`,
"Do not respond before attempting to write the file.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// CRITICAL: This is the behavior we're testing
// waitForAgentIdle should return when permission is requested,
// NOT wait until the agent is fully idle (which would timeout or
// require us to approve the permission first)
const startTime = Date.now();
const state = await ctx.client.waitForAgentIdle(agent.id, 60000);
const waitDuration = Date.now() - startTime;
// If wait returns because of permission request, we should have:
// 1. Agent status still "running" (not idle yet - waiting for permission)
// 2. Pending permissions in the state
// 3. Wait should return quickly (under 30 seconds, not timeout)
// Check that we have a pending permission
const hasPendingPermission =
(state.pendingPermissions && state.pendingPermissions.length > 0);
// Log for debugging
console.log("Wait returned after", waitDuration, "ms");
console.log("Agent status:", state.status);
console.log("Pending permissions:", state.pendingPermissions?.length ?? 0);
// THE ASSERTION:
// If waitForAgentIdle correctly yields on permission request,
// we should either:
// - Get a state with pending permissions (status might be "running")
// - Or the state should be from right when permission was requested
//
// If waitForAgentIdle does NOT yield on permission request,
// this test will either:
// - Timeout (60s)
// - Return only after we never approve permission and agent errors/gives up
// This test will FAIL if waitForAgentIdle waits for full idle
// instead of returning on permission request
expect(hasPendingPermission).toBe(true);
// Also verify we can get the permission via waitForPermission
// (This should return immediately since permission is already pending)
const permission = await ctx.client.waitForPermission(agent.id, 5000);
expect(permission).toBeTruthy();
expect(permission.kind).toBe("tool");
// Clean up: deny the permission so agent can finish
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Test complete",
});
// Wait for agent to finish processing the denial
await ctx.client.waitForAgentIdle(agent.id, 30000);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
});
});
+3
View File
@@ -15,6 +15,9 @@ export type {
AgentTimelineItem,
} from "./agent/agent-sdk-types.js";
// Agent activity curator for CLI logs
export { curateAgentActivity } from "./agent/activity-curator.js";
// WebSocket message types for CLI streaming
export type {
AgentSnapshotPayload,
+2 -1
View File
@@ -623,6 +623,7 @@ export class Session {
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: record.labels,
};
}
@@ -654,7 +655,7 @@ export class Session {
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
}
await this.agentManager.primeAgentHistory(agentId);