feat(agent): add git diff viewer and improve permission handling
- Add git diff viewer screen accessible via agent dropdown menu - Implement git_diff_request/response WebSocket messages - Add dropdown menu to agent screen header with "View Changes" option - Clear pending permissions when starting new agent turn to prevent conflicts - Treat agent refusals as completed rather than failed state - Add test coverage for permission handling edge cases - Configure vitest to run in single-threaded mode for stability - Remove redundant wrapper in agent stream view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,83 @@ describe("AgentManager", () => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should not fail when sending '.' after plan permission request", async () => {
|
||||
const manager = new AgentManager();
|
||||
let permissionRequest: RequestPermissionRequest | null = null;
|
||||
let requestId: string | null = null;
|
||||
|
||||
const agentId = await manager.createAgent({
|
||||
cwd: tmpDir,
|
||||
type: "claude",
|
||||
initialMode: "plan",
|
||||
});
|
||||
createdAgents.push({ manager, agentId });
|
||||
|
||||
const unsubscribe = manager.subscribeToUpdates(
|
||||
agentId,
|
||||
(update: AgentUpdate) => {
|
||||
const notification: AgentNotification = update.notification;
|
||||
if (notification.type === "permission") {
|
||||
permissionRequest = notification.request;
|
||||
requestId = notification.requestId;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await manager.sendPrompt(
|
||||
agentId,
|
||||
"Create a file called test.txt with the content 'hello world'"
|
||||
);
|
||||
|
||||
let attempts = 0;
|
||||
while (!permissionRequest && attempts < 40) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(permissionRequest).toBeDefined();
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
console.log("Permission request received, now sending '.' message instead of responding");
|
||||
|
||||
// Instead of responding to permission, send a "." message
|
||||
await manager.sendPrompt(agentId, ".");
|
||||
|
||||
// Wait for agent to finish processing
|
||||
let status = manager.getAgentStatus(agentId);
|
||||
let waitAttempts = 0;
|
||||
while (status === "processing" && waitAttempts < 60) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
status = manager.getAgentStatus(agentId);
|
||||
waitAttempts++;
|
||||
}
|
||||
|
||||
const agentInfo = manager.listAgents().find((a) => a.id === agentId);
|
||||
const updates = manager.getAgentUpdates(agentId);
|
||||
const sessionUpdates = updates.filter(u => u.notification.type === "session");
|
||||
|
||||
console.log("Agent status after '.' message:", status);
|
||||
console.log("Agent error:", agentInfo?.error);
|
||||
console.log("Session updates count:", sessionUpdates.length);
|
||||
console.log("Last few updates:", updates.slice(-5).map(u => ({
|
||||
type: u.notification.type,
|
||||
timestamp: u.timestamp,
|
||||
})));
|
||||
|
||||
// The agent should NOT be in failed state
|
||||
expect(status).not.toBe("failed");
|
||||
|
||||
// The agent should be in a usable state (ready, completed, or processing)
|
||||
expect(["ready", "completed", "processing"]).toContain(status);
|
||||
|
||||
// There should be no error
|
||||
expect(agentInfo?.error).toBeNull();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
describe("persistence", () => {
|
||||
it("should load persisted agent and send new prompt", async () => {
|
||||
const manager = new AgentManager();
|
||||
|
||||
@@ -416,6 +416,46 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any pending permissions since we're starting a new turn
|
||||
if (agent.pendingPermissions.size > 0) {
|
||||
console.log(
|
||||
`[Agent ${agentId}] Clearing ${agent.pendingPermissions.size} pending permission(s)`
|
||||
);
|
||||
|
||||
// Reject all pending permission promises with cancellation
|
||||
for (const [requestId, permission] of agent.pendingPermissions) {
|
||||
permission.resolve({
|
||||
outcome: {
|
||||
outcome: "cancelled" as const,
|
||||
},
|
||||
});
|
||||
|
||||
// Emit permission_resolved notification so UI updates
|
||||
const agentUpdate: AgentUpdate = {
|
||||
agentId,
|
||||
timestamp: new Date(),
|
||||
notification: {
|
||||
type: "permission_resolved",
|
||||
requestId,
|
||||
agentId,
|
||||
optionId: "cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
agent.updates.push(agentUpdate);
|
||||
|
||||
for (const subscriber of agent.subscribers) {
|
||||
try {
|
||||
subscriber(agentUpdate);
|
||||
} catch (error) {
|
||||
console.error(`[Agent ${agentId}] Subscriber error:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
// Get runtime (guaranteed to exist after ensureInitialized)
|
||||
if (
|
||||
agent.state.type !== "ready" &&
|
||||
@@ -506,14 +546,14 @@ export class AgentManager {
|
||||
stopReason: response.stopReason,
|
||||
};
|
||||
} else if (response.stopReason === "refusal") {
|
||||
console.error(
|
||||
console.warn(
|
||||
`[Agent ${agentId}] Agent refused to process the prompt`,
|
||||
response
|
||||
);
|
||||
agent.state = {
|
||||
type: "failed",
|
||||
lastError: "Agent refused to process the prompt",
|
||||
type: "completed",
|
||||
runtime: agent.state.runtime,
|
||||
stopReason: response.stopReason,
|
||||
};
|
||||
} else if (response.stopReason === "cancelled") {
|
||||
agent.state = { type: "ready", runtime: agent.state.runtime };
|
||||
|
||||
@@ -122,6 +122,11 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
optionId: z.string(),
|
||||
});
|
||||
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UserTextMessageSchema,
|
||||
RealtimeAudioChunkMessageSchema,
|
||||
@@ -137,6 +142,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
InitializeAgentRequestMessageSchema,
|
||||
SetAgentModeMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -321,6 +327,15 @@ export const AgentPermissionResolvedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const GitDiffResponseSchema = z.object({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
diff: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -338,6 +353,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
DeleteConversationResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
GitDiffResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<
|
||||
@@ -374,6 +390,8 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
|
||||
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
||||
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Level Messages (wraps session messages)
|
||||
|
||||
@@ -518,6 +518,10 @@ export class Session {
|
||||
msg.optionId
|
||||
);
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -1016,6 +1020,62 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle git diff request for an agent
|
||||
*/
|
||||
private async handleGitDiffRequest(agentId: string): Promise<void> {
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Handling git diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: `Agent not found: ${agentId}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git diff HEAD", {
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: stdout,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Git diff for agent ${agentId} completed (${stdout.length} bytes)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to get git diff for agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send current session state (live agents and commands) to client
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user