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:
Mohamed Boudra
2025-10-26 22:19:16 +01:00
co-authored by Claude
parent 70a1852e57
commit 086dde30cc
11 changed files with 627 additions and 29 deletions
@@ -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 };
+18
View File
@@ -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)
+60
View File
@@ -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
*/