refactor: decouple permission state from attention signal
This commit is contained in:
@@ -185,6 +185,7 @@ function SidebarAgentRow({
|
|||||||
const relativeCreatedAt = formatTimeAgo(entry.agent.createdAt);
|
const relativeCreatedAt = formatTimeAgo(entry.agent.createdAt);
|
||||||
const isActive = isSidebarActiveAgent({
|
const isActive = isSidebarActiveAgent({
|
||||||
status: entry.agent.status,
|
status: entry.agent.status,
|
||||||
|
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||||
requiresAttention: entry.agent.requiresAttention,
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
attentionReason: entry.agent.attentionReason,
|
attentionReason: entry.agent.attentionReason,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
lastActivityAt: agent.lastActivityAt,
|
lastActivityAt: agent.lastActivityAt,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
|
pendingPermissionCount: agent.pendingPermissions.length,
|
||||||
requiresAttention: agent.requiresAttention,
|
requiresAttention: agent.requiresAttention,
|
||||||
attentionReason: agent.attentionReason,
|
attentionReason: agent.attentionReason,
|
||||||
attentionTimestamp: agent.attentionTimestamp,
|
attentionTimestamp: agent.attentionTimestamp,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function toAggregatedAgent(params: {
|
|||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: source.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
|
pendingPermissionCount: source.pendingPermissions.length,
|
||||||
requiresAttention: source.requiresAttention,
|
requiresAttention: source.requiresAttention,
|
||||||
attentionReason: source.attentionReason,
|
attentionReason: source.attentionReason,
|
||||||
attentionTimestamp: source.attentionTimestamp ?? null,
|
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ function isMatch(agent: AggregatedAgent, query: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
|
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||||
|
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||||
|
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||||
|
|
||||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ function deriveFaviconStatus(
|
|||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
const hasAttention = agents.some((agent) => agent.requiresAttention);
|
const hasAttention = agents.some((agent) => agent.requiresAttention);
|
||||||
if (hasAttention) {
|
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
|
||||||
|
if (hasAttention || hasNeedsInput) {
|
||||||
return "attention";
|
return "attention";
|
||||||
}
|
}
|
||||||
return "none";
|
return "none";
|
||||||
@@ -41,8 +42,8 @@ function deriveMacDockBadgeCount(
|
|||||||
): number | undefined {
|
): number | undefined {
|
||||||
const attentionCount = agents.filter(
|
const attentionCount = agents.filter(
|
||||||
(agent) =>
|
(agent) =>
|
||||||
agent.requiresAttention &&
|
(agent.pendingPermissionCount ?? 0) > 0 ||
|
||||||
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
|
(agent.requiresAttention && agent.attentionReason === "finished")
|
||||||
).length;
|
).length;
|
||||||
if (attentionCount > 0) {
|
if (attentionCount > 0) {
|
||||||
return attentionCount;
|
return attentionCount;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function applySidebarDefaultOrdering(
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const bucket = deriveSidebarStateBucket({
|
const bucket = deriveSidebarStateBucket({
|
||||||
status: entry.agent.status,
|
status: entry.agent.status,
|
||||||
|
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||||
requiresAttention: entry.agent.requiresAttention,
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
attentionReason: entry.agent.attentionReason,
|
attentionReason: entry.agent.attentionReason,
|
||||||
});
|
});
|
||||||
@@ -135,6 +136,7 @@ function toAggregatedAgent(params: {
|
|||||||
lastActivityAt: params.lastActivityAt,
|
lastActivityAt: params.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
|
pendingPermissionCount: source.pendingPermissions.length,
|
||||||
requiresAttention: source.requiresAttention,
|
requiresAttention: source.requiresAttention,
|
||||||
attentionReason: source.attentionReason,
|
attentionReason: source.attentionReason,
|
||||||
attentionTimestamp: source.attentionTimestamp ?? null,
|
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||||
@@ -239,6 +241,7 @@ export function useSidebarAgentsList(options?: {
|
|||||||
const existing = byProject.get(entry.project.projectKey);
|
const existing = byProject.get(entry.project.projectKey);
|
||||||
const isActive = isSidebarActiveAgent({
|
const isActive = isSidebarActiveAgent({
|
||||||
status: entry.agent.status,
|
status: entry.agent.status,
|
||||||
|
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||||
requiresAttention: entry.agent.requiresAttention,
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
attentionReason: entry.agent.attentionReason,
|
attentionReason: entry.agent.attentionReason,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -924,6 +924,7 @@ function AgentScreenContent({
|
|||||||
focusedAgentId,
|
focusedAgentId,
|
||||||
isConnected,
|
isConnected,
|
||||||
requiresAttention: agent?.requiresAttention,
|
requiresAttention: agent?.requiresAttention,
|
||||||
|
attentionReason: agent?.attentionReason,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
const sortedAgents = useMemo(() => {
|
const sortedAgents = useMemo(() => {
|
||||||
return [...agents].sort((a, b) => {
|
return [...agents].sort((a, b) => {
|
||||||
|
const aNeedsInput = (a.pendingPermissionCount ?? 0) > 0;
|
||||||
|
const bNeedsInput = (b.pendingPermissionCount ?? 0) > 0;
|
||||||
|
if (aNeedsInput && !bNeedsInput) return -1;
|
||||||
|
if (!aNeedsInput && bNeedsInput) return 1;
|
||||||
|
|
||||||
if (a.requiresAttention && !b.requiresAttention) return -1;
|
if (a.requiresAttention && !b.requiresAttention) return -1;
|
||||||
if (!a.requiresAttention && b.requiresAttention) return 1;
|
if (!a.requiresAttention && b.requiresAttention) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -995,6 +995,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
|
pendingPermissionCount: agent.pendingPermissions.length,
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
attentionTimestamp: agent.attentionTimestamp ?? null,
|
attentionTimestamp: agent.attentionTimestamp ?? null,
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ export type AgentDirectoryEntry = Pick<
|
|||||||
| "attentionTimestamp"
|
| "attentionTimestamp"
|
||||||
| "archivedAt"
|
| "archivedAt"
|
||||||
| "labels"
|
| "labels"
|
||||||
>;
|
> & {
|
||||||
|
pendingPermissionCount?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
focusedAgentId: "agent-1",
|
focusedAgentId: "agent-1",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
|
attentionReason: "finished",
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -21,6 +22,7 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
focusedAgentId: "agent-1",
|
focusedAgentId: "agent-1",
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
|
attentionReason: "finished",
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -32,6 +34,7 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
focusedAgentId: "agent-2",
|
focusedAgentId: "agent-2",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
|
attentionReason: "finished",
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -43,6 +46,7 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
focusedAgentId: "agent-1",
|
focusedAgentId: "agent-1",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -54,6 +58,19 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
focusedAgentId: "agent-1",
|
focusedAgentId: "agent-1",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
|
attentionReason: "finished",
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for permission-shaped attention", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearAgentAttentionOnView({
|
||||||
|
agentId: "agent-1",
|
||||||
|
focusedAgentId: "agent-1",
|
||||||
|
isConnected: true,
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "permission",
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ interface ShouldClearAgentAttentionOnViewInput {
|
|||||||
focusedAgentId: string | null | undefined;
|
focusedAgentId: string | null | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
requiresAttention: boolean | null | undefined;
|
requiresAttention: boolean | null | undefined;
|
||||||
|
attentionReason?: "finished" | "error" | "permission" | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldClearAgentAttentionOnView(
|
export function shouldClearAgentAttentionOnView(
|
||||||
@@ -18,5 +19,8 @@ export function shouldClearAgentAttentionOnView(
|
|||||||
if (!input.requiresAttention) {
|
if (!input.requiresAttention) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (input.attentionReason === "permission") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return input.focusedAgentId === agentId;
|
return input.focusedAgentId === agentId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export interface ProjectGroup {
|
|||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
/** Number of truly active agents (running or requires attention) */
|
/** Number of truly active agents (running, needs input, or requires attention) */
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
/** Total agents before any limit was applied */
|
/** Total agents before any limit was applied */
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
@@ -222,11 +222,11 @@ const MAX_INACTIVE_PER_PROJECT = 5;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups agents into active (by project) and inactive (by date) sections.
|
* Groups agents into active (by project) and inactive (by date) sections.
|
||||||
* Active = running, requires attention, or had activity within the grace period (24 hours).
|
* Active = running, needs input, requires attention, or had activity within the grace period.
|
||||||
*
|
*
|
||||||
* Within each project group:
|
* Within each project group:
|
||||||
* - All truly active agents (running/requires attention) are always shown
|
* - All truly active agents (running/needs input/requires attention) are always shown
|
||||||
* - Recently active (within grace period but not running) are limited to MAX_INACTIVE_PER_PROJECT
|
* - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT
|
||||||
*/
|
*/
|
||||||
export function groupAgents(
|
export function groupAgents(
|
||||||
agents: AggregatedAgent[],
|
agents: AggregatedAgent[],
|
||||||
@@ -244,7 +244,9 @@ export function groupAgents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRunningOrAttention =
|
const isRunningOrAttention =
|
||||||
agent.status === "running" || agent.requiresAttention;
|
agent.status === "running" ||
|
||||||
|
agent.requiresAttention ||
|
||||||
|
(agent.pendingPermissionCount ?? 0) > 0;
|
||||||
const ageDiff = now - agent.lastActivityAt.getTime();
|
const ageDiff = now - agent.lastActivityAt.getTime();
|
||||||
const isRecentlyActive = ageDiff < ACTIVE_GRACE_PERIOD_MS;
|
const isRecentlyActive = ageDiff < ACTIVE_GRACE_PERIOD_MS;
|
||||||
const isActive = isRunningOrAttention || isRecentlyActive;
|
const isActive = isRunningOrAttention || isRecentlyActive;
|
||||||
@@ -272,7 +274,9 @@ export function groupAgents(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isTrulyActive =
|
const isTrulyActive =
|
||||||
agent.status === "running" || agent.requiresAttention;
|
agent.status === "running" ||
|
||||||
|
agent.requiresAttention ||
|
||||||
|
(agent.pendingPermissionCount ?? 0) > 0;
|
||||||
if (isTrulyActive) {
|
if (isTrulyActive) {
|
||||||
existing.trulyActive.push(agent);
|
existing.trulyActive.push(agent);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||||
|
|
||||||
|
describe("deriveSidebarStateBucket", () => {
|
||||||
|
it("prioritizes pending permissions as needs_input", () => {
|
||||||
|
expect(
|
||||||
|
deriveSidebarStateBucket({
|
||||||
|
status: "idle",
|
||||||
|
pendingPermissionCount: 1,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
})
|
||||||
|
).toBe("needs_input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy permission attention in needs_input", () => {
|
||||||
|
expect(
|
||||||
|
deriveSidebarStateBucket({
|
||||||
|
status: "idle",
|
||||||
|
pendingPermissionCount: 0,
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "permission",
|
||||||
|
})
|
||||||
|
).toBe("needs_input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats unread finished agents as attention", () => {
|
||||||
|
expect(
|
||||||
|
deriveSidebarStateBucket({
|
||||||
|
status: "idle",
|
||||||
|
pendingPermissionCount: 0,
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "finished",
|
||||||
|
})
|
||||||
|
).toBe("attention");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,10 +16,16 @@ export type SidebarStateBucket =
|
|||||||
|
|
||||||
export function deriveSidebarStateBucket(input: {
|
export function deriveSidebarStateBucket(input: {
|
||||||
status: AgentLifecycleStatus;
|
status: AgentLifecycleStatus;
|
||||||
|
pendingPermissionCount?: number;
|
||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: SidebarAttentionReason;
|
attentionReason?: SidebarAttentionReason;
|
||||||
}): SidebarStateBucket {
|
}): SidebarStateBucket {
|
||||||
if (input.requiresAttention && input.attentionReason === "permission") {
|
if ((input.pendingPermissionCount ?? 0) > 0) {
|
||||||
|
return "needs_input";
|
||||||
|
}
|
||||||
|
// Legacy fallback for snapshots persisted before permission state was decoupled
|
||||||
|
// from unread attention.
|
||||||
|
if (input.attentionReason === "permission") {
|
||||||
return "needs_input";
|
return "needs_input";
|
||||||
}
|
}
|
||||||
if (input.status === "error" || input.attentionReason === "error") {
|
if (input.status === "error" || input.attentionReason === "error") {
|
||||||
@@ -37,6 +43,7 @@ export function deriveSidebarStateBucket(input: {
|
|||||||
|
|
||||||
export function isSidebarActiveAgent(input: {
|
export function isSidebarActiveAgent(input: {
|
||||||
status: AgentLifecycleStatus;
|
status: AgentLifecycleStatus;
|
||||||
|
pendingPermissionCount?: number;
|
||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: SidebarAttentionReason;
|
attentionReason?: SidebarAttentionReason;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
|||||||
@@ -1699,6 +1699,171 @@ describe("AgentManager", () => {
|
|||||||
expect(attentionCalls).toHaveLength(0);
|
expect(attentionCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
|
class FailingSession extends TestAgentSession {
|
||||||
|
private attempt = 0;
|
||||||
|
|
||||||
|
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
this.attempt += 1;
|
||||||
|
yield { type: "turn_started", provider: this.provider };
|
||||||
|
throw new Error(`boom-${this.attempt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FailingClient implements AgentClient {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
return new FailingSession(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||||
|
return new FailingSession({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: config?.cwd ?? process.cwd(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new FailingClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
||||||
|
onAgentAttention: ({ reason }) => {
|
||||||
|
attentionReasons.push(reason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
title: "Attention transition test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1");
|
||||||
|
|
||||||
|
const afterFirstFailure = manager.getAgent(agent.id);
|
||||||
|
expect(afterFirstFailure?.lifecycle).toBe("error");
|
||||||
|
expect(afterFirstFailure?.attention.requiresAttention).toBe(true);
|
||||||
|
expect(afterFirstFailure?.attention).toMatchObject({
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.clearAgentAttention(agent.id);
|
||||||
|
manager.notifyAgentState(agent.id);
|
||||||
|
|
||||||
|
const afterClear = manager.getAgent(agent.id);
|
||||||
|
expect(afterClear?.lifecycle).toBe("error");
|
||||||
|
expect(afterClear?.attention).toEqual({ requiresAttention: false });
|
||||||
|
|
||||||
|
await expect(manager.runAgent(agent.id, "fail again")).rejects.toThrow("boom-2");
|
||||||
|
|
||||||
|
const afterSecondFailure = manager.getAgent(agent.id);
|
||||||
|
expect(afterSecondFailure?.lifecycle).toBe("error");
|
||||||
|
expect(afterSecondFailure?.attention).toMatchObject({
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "error",
|
||||||
|
});
|
||||||
|
expect(attentionReasons).toEqual(["error", "error"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission request notifies once without forcing unread attention state", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
|
class PermissionSession extends TestAgentSession {
|
||||||
|
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
yield { type: "turn_started", provider: this.provider };
|
||||||
|
yield {
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: this.provider,
|
||||||
|
request: {
|
||||||
|
id: "perm-1",
|
||||||
|
provider: this.provider,
|
||||||
|
kind: "tool",
|
||||||
|
name: "Read file",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: this.provider,
|
||||||
|
requestId: "perm-1",
|
||||||
|
resolution: { behavior: "allow" },
|
||||||
|
};
|
||||||
|
yield { type: "turn_completed", provider: this.provider };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionClient implements AgentClient {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
return new PermissionSession(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||||
|
return new PermissionSession({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: config?.cwd ?? process.cwd(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new PermissionClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
||||||
|
onAgentAttention: ({ reason }) => {
|
||||||
|
attentionReasons.push(reason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
title: "Permission transition test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = manager.streamAgent(agent.id, "permission flow");
|
||||||
|
await stream.next(); // turn_started
|
||||||
|
await stream.next(); // permission_requested
|
||||||
|
|
||||||
|
const withPermissionPending = manager.getAgent(agent.id);
|
||||||
|
expect(withPermissionPending?.pendingPermissions.size).toBe(1);
|
||||||
|
expect(withPermissionPending?.attention).toEqual({ requiresAttention: false });
|
||||||
|
|
||||||
|
// Drain the rest of the stream to close cleanly.
|
||||||
|
while (!(await stream.next()).done) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(attentionReasons).toContain("permission");
|
||||||
|
});
|
||||||
|
|
||||||
test("respondToPermission updates currentModeId after plan approval", async () => {
|
test("respondToPermission updates currentModeId after plan approval", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -1776,7 +1776,13 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "permission_requested":
|
case "permission_requested":
|
||||||
agent.pendingPermissions.set(event.request.id, event.request);
|
{
|
||||||
|
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||||
|
agent.pendingPermissions.set(event.request.id, event.request);
|
||||||
|
if (!hadPendingPermissions && !agent.internal) {
|
||||||
|
this.broadcastAgentAttention(agent, "permission");
|
||||||
|
}
|
||||||
|
}
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
break;
|
break;
|
||||||
case "permission_resolved":
|
case "permission_resolved":
|
||||||
@@ -1849,7 +1855,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitState(agent: ManagedAgent): void {
|
private emitState(agent: ManagedAgent): void {
|
||||||
// Check if attention should be set based on status change
|
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||||
this.checkAndSetAttention(agent);
|
this.checkAndSetAttention(agent);
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
@@ -1888,7 +1894,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if agent entered error state
|
// Check if agent entered error state
|
||||||
if (currentStatus === "error") {
|
if (previousStatus !== "error" && currentStatus === "error") {
|
||||||
agent.attention = {
|
agent.attention = {
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "error",
|
attentionReason: "error",
|
||||||
@@ -1898,18 +1904,6 @@ export class AgentManager {
|
|||||||
this.enqueueBackgroundPersist(agent);
|
this.enqueueBackgroundPersist(agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if agent has pending permissions
|
|
||||||
if (agent.pendingPermissions.size > 0) {
|
|
||||||
agent.attention = {
|
|
||||||
requiresAttention: true,
|
|
||||||
attentionReason: "permission",
|
|
||||||
attentionTimestamp: new Date(),
|
|
||||||
};
|
|
||||||
this.broadcastAgentAttention(agent, "permission");
|
|
||||||
this.enqueueBackgroundPersist(agent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueueBackgroundPersist(agent: ManagedAgent): void {
|
private enqueueBackgroundPersist(agent: ManagedAgent): void {
|
||||||
|
|||||||
@@ -4894,9 +4894,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getStatusPriority(agent: AgentSnapshotPayload): number {
|
private getStatusPriority(agent: AgentSnapshotPayload): number {
|
||||||
const requiresAttention = agent.requiresAttention ?? false
|
|
||||||
const attentionReason = agent.attentionReason ?? null
|
const attentionReason = agent.attentionReason ?? null
|
||||||
if (requiresAttention && attentionReason === 'permission') {
|
const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0
|
||||||
|
if (hasPendingPermission || attentionReason === 'permission') {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if (agent.status === 'error' || attentionReason === 'error') {
|
if (agent.status === 'error' || attentionReason === 'error') {
|
||||||
|
|||||||
Reference in New Issue
Block a user