mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
Dismiss a failed agent's red dot by reading it (#8831)
* fix(sidebar): clear the failed status once the error is read Reading an errored agent already cleared its error attention, but the state bucket also mapped lifecycle status "error" to failed. Lifecycle only leaves "error" when the next turn starts, so the red dot and the Failed group stuck until the agent was prompted again. The failed bucket now follows unread error attention, the same way the green dot follows unread finished attention; lifecycle status stays "error". Because failed now depends on attention, a failure must replace unread finished attention, otherwise a reload that fails on an unread agent would show green. * fix(sidebar): keep failed subagents red and let read failures be marked unread The failed bucket now follows unread error attention, which broke three places that relied on lifecycle "error" alone: - Subagent track rows and provider-subagent tabs have no read state of their own, so failed children lost their red mark. They now map the child's lifecycle directly. - A read errored workspace became "done" and offered Mark as unread, but the daemon only accepted idle or closed agents. Marking it unread now restores error attention. - A reload that failed again while the lifecycle was already "error" raised no new attention. A failed reload now raises error attention directly instead of relying on a lifecycle transition.
This commit is contained in:
+10
-3
@@ -305,9 +305,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
@@ -316,6 +316,13 @@ untouched. Forging either makes a background resume look like the user read a wo
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||
|
||||
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
|
||||
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
|
||||
green one. For the same reason every new failure raises `error` attention, including a failed reload
|
||||
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
|
||||
agent would show the green dot or none. Subagents track marks are the exception: they follow the
|
||||
child's lifecycle, because the track is where a failed child stays visible.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSubagentStatusBucket } from "@/subagents";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
@@ -107,10 +107,7 @@ function useProviderSubagentDescriptor(
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: useProviderIcon(provider, context.serverId),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
requiresAttention: descriptor.status === "failed",
|
||||
})
|
||||
? deriveSubagentStatusBucket(providerSubagentLifecycleStatus(descriptor.status))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export {
|
||||
type UseArchiveFinishedSubagentsInput,
|
||||
} from "./use-archive-finished";
|
||||
export { useSubagentSheetTab } from "./sheet-tab";
|
||||
export { deriveSubagentStatusBucket } from "./track-presentation";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ComposerTrackPillSegment } from "@/composer/tracks";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
|
||||
import type { SubagentRow } from "./select";
|
||||
import { isFinishedSubagent } from "./archive-finished";
|
||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
||||
@@ -14,6 +15,16 @@ function presentationStatus(row: SubagentRow) {
|
||||
return providerSubagentLifecycleStatus(row.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* A child's mark reports what it is doing, not whether anyone read it: the track is where a
|
||||
* failed child stays visible after the parent's own attention is cleared.
|
||||
*/
|
||||
export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket {
|
||||
if (status === "error") return "failed";
|
||||
if (status === "running") return "running";
|
||||
return "done";
|
||||
}
|
||||
|
||||
export interface SubagentRowPresentationData {
|
||||
key: string;
|
||||
kind: "agent";
|
||||
@@ -38,10 +49,7 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP
|
||||
label: label ?? "",
|
||||
subtitle: subtitle ?? "",
|
||||
titleState: label ? "ready" : "loading",
|
||||
statusBucket: deriveSidebarStateBucket({
|
||||
status,
|
||||
requiresAttention: false,
|
||||
}),
|
||||
statusBucket: deriveSubagentStatusBucket(status),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ describe("workspace agent activity index", () => {
|
||||
workspaceId: "workspace-error",
|
||||
status: "error",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -39,6 +39,28 @@ describe("deriveAgentStateBucket", () => {
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("treats an errored agent whose error was read as done", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "error",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("done");
|
||||
});
|
||||
|
||||
it("treats an errored agent with unread error attention as failed", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "error",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
}),
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("treats unread finished agents as attention", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
@@ -83,10 +105,21 @@ describe("getAgentStatusPriority", () => {
|
||||
it("prioritizes pending permissions before errors and running agents", () => {
|
||||
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
|
||||
getAgentStatusPriority({
|
||||
status: "error",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
}),
|
||||
);
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks an errored agent with a read error alongside completed agents", () => {
|
||||
expect(getAgentStatusPriority({ status: "error", requiresAttention: false })).toBe(
|
||||
getAgentStatusPriority({ status: "idle" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,17 @@ const WORKSPACE_STATE_BUCKET_PRIORITY = {
|
||||
done: 4,
|
||||
} as const satisfies Record<WorkspaceStateBucket, number>;
|
||||
|
||||
// An error is a notification like a finish: once the user has seen it, the agent stops showing as
|
||||
// failed even though its lifecycle stays "error" until the next turn starts.
|
||||
function hasUnreadError(input: AgentStateBucketInput): boolean {
|
||||
return input.requiresAttention === true && input.attentionReason === "error";
|
||||
}
|
||||
|
||||
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
if (hasUnreadError(input)) {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
@@ -43,7 +49,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return 0;
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
if (hasUnreadError(input)) {
|
||||
return 1;
|
||||
}
|
||||
if (input.status === "running") {
|
||||
|
||||
@@ -2801,6 +2801,53 @@ test("closing during reload leaves the replacement closed", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("every failed reload raises error attention, replacing unread finished attention", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-error-attention-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldReloadCloseClient();
|
||||
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
rescueTimeouts: { reloadSessionCloseMs: 10 },
|
||||
onAgentAttention: ({ reason }) => attentionReasons.push(reason),
|
||||
});
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.markAgentUnread(created.id);
|
||||
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
||||
attentionReason: "finished",
|
||||
});
|
||||
|
||||
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing");
|
||||
|
||||
expect(manager.getAgent(created.id)?.lifecycle).toBe("error");
|
||||
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
expect(attentionReasons).toEqual(["error"]);
|
||||
|
||||
await manager.clearAgentAttention(created.id);
|
||||
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing");
|
||||
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
expect(attentionReasons).toEqual(["error", "error"]);
|
||||
|
||||
client.finishClosing();
|
||||
await manager.closeAgent(created.id);
|
||||
} finally {
|
||||
client.finishClosing();
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrying a timed-out reload waits for the original close to finish", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
@@ -5807,6 +5854,52 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async (
|
||||
expect(last.lifecycle).toBe("closed");
|
||||
});
|
||||
|
||||
test("markAgentUnread restores error attention on a read errored agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-error-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
class ErrorClient extends TestAgentClient {
|
||||
readonly session = new TestAgentSession({ provider: "codex", cwd: workdir });
|
||||
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
const client = new ErrorClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const running = waitForAgentLifecycle(manager, agent.id, "running");
|
||||
client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" });
|
||||
await running;
|
||||
const failed = waitForAgentLifecycle(manager, agent.id, "error");
|
||||
client.session.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "failed-turn",
|
||||
error: "Selected model is at capacity.",
|
||||
});
|
||||
await failed;
|
||||
await manager.clearAgentAttention(agent.id);
|
||||
|
||||
await manager.markAgentUnread(agent.id);
|
||||
|
||||
expect(manager.getAgent(agent.id)?.attention).toMatchObject({
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
expect(await storage.get(agent.id)).toMatchObject({
|
||||
lastStatus: "error",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
});
|
||||
|
||||
test("markAgentUnread dispatches stored attention to every subscriber", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
|
||||
@@ -411,6 +411,13 @@ function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">):
|
||||
return !agent.internal && !agent.background;
|
||||
}
|
||||
|
||||
/** The attention a read agent gets back when marked unread; null while it is still working. */
|
||||
function unreadAttentionReason(status: AgentLifecycleStatus): "finished" | "error" | null {
|
||||
if (status === "idle" || status === "closed") return "finished";
|
||||
if (status === "error") return "error";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
||||
if (input == null || !input.requiresAttention) {
|
||||
return { requiresAttention: false };
|
||||
@@ -1776,6 +1783,7 @@ export class AgentManager {
|
||||
} else if (this.agents.get(agentId) === existing) {
|
||||
existing.lifecycle = "error";
|
||||
existing.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.raiseErrorAttention(existing);
|
||||
this.emitState(existing);
|
||||
}
|
||||
throw error;
|
||||
@@ -2433,16 +2441,16 @@ export class AgentManager {
|
||||
async markAgentUnread(agentId: string): Promise<void> {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
const isFinished = liveAgent.lifecycle === "idle";
|
||||
const attentionReason = unreadAttentionReason(liveAgent.lifecycle);
|
||||
const hasPendingPermissions = liveAgent.pendingPermissions.size > 0;
|
||||
const canMarkUnread =
|
||||
isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
|
||||
attentionReason && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
|
||||
if (!canMarkUnread) {
|
||||
throw new Error(`Agent is no longer finished and read: ${agentId}`);
|
||||
throw new Error(`Agent is no longer settled and read: ${agentId}`);
|
||||
}
|
||||
liveAgent.attention = {
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
attentionReason,
|
||||
attentionTimestamp: new Date(),
|
||||
};
|
||||
await this.persistSnapshot(liveAgent);
|
||||
@@ -2452,18 +2460,18 @@ export class AgentManager {
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
const record = await registry.get(agentId);
|
||||
const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed";
|
||||
const attentionReason = record?.lastStatus ? unreadAttentionReason(record.lastStatus) : null;
|
||||
const canMarkUnread =
|
||||
record && !record.internal && !record.archivedAt && !record.requiresAttention;
|
||||
if (!canMarkUnread || !hasFinishedStatus) {
|
||||
throw new Error(`Agent is no longer finished and read: ${agentId}`);
|
||||
if (!canMarkUnread || !attentionReason) {
|
||||
throw new Error(`Agent is no longer settled and read: ${agentId}`);
|
||||
}
|
||||
const updatedAt = this.nextStoredUpdatedAt(record);
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
updatedAt,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
attentionReason,
|
||||
attentionTimestamp: updatedAt,
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
@@ -5300,12 +5308,15 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already requires attention
|
||||
if (previousStatus !== "error" && currentStatus === "error") {
|
||||
this.raiseErrorAttention(agent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent.attention.requiresAttention) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if agent transitioned from running to idle (finished)
|
||||
if (previousStatus === "running" && currentStatus === "idle") {
|
||||
agent.attention = {
|
||||
requiresAttention: true,
|
||||
@@ -5313,19 +5324,25 @@ export class AgentManager {
|
||||
attentionTimestamp: new Date(),
|
||||
};
|
||||
this.broadcastAgentAttention(agent, "finished");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if agent entered error state
|
||||
if (previousStatus !== "error" && currentStatus === "error") {
|
||||
agent.attention = {
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
attentionTimestamp: new Date(),
|
||||
};
|
||||
this.broadcastAgentAttention(agent, "error");
|
||||
// The failed state is only shown while error attention is unread, so every new failure raises
|
||||
// it, including one that keeps the lifecycle at "error". An error outranks an unread finish,
|
||||
// which would otherwise make the failure look green.
|
||||
private raiseErrorAttention(agent: ManagedAgent): void {
|
||||
if (!raisesAttention(agent)) {
|
||||
return;
|
||||
}
|
||||
if (agent.attention.requiresAttention && agent.attention.attentionReason !== "finished") {
|
||||
return;
|
||||
}
|
||||
agent.attention = {
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
attentionTimestamp: new Date(),
|
||||
};
|
||||
this.broadcastAgentAttention(agent, "error");
|
||||
}
|
||||
|
||||
private enqueueBackgroundPersist(agent: ManagedAgent): void {
|
||||
|
||||
@@ -7955,7 +7955,10 @@ export class Session {
|
||||
.filter((agent) => !agent.archivedAt && !agent.background)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id)
|
||||
.filter((agent) => agent.status === "idle" || agent.status === "closed")
|
||||
.filter(
|
||||
(agent) =>
|
||||
agent.status === "idle" || agent.status === "closed" || agent.status === "error",
|
||||
)
|
||||
.filter((agent) => agent.requiresAttention !== true)
|
||||
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
|
||||
.sort(
|
||||
@@ -7964,7 +7967,7 @@ export class Session {
|
||||
);
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) {
|
||||
throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`);
|
||||
throw new Error(`Workspace has no read agent to mark unread: ${workspaceId}`);
|
||||
}
|
||||
|
||||
await this.agentManager.markAgentUnread(candidate.id);
|
||||
|
||||
@@ -2123,7 +2123,53 @@ test("workspace mark unread selects a finished cross-workspace child with scoped
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace mark unread rejects workspaces without a finished root agent", async () => {
|
||||
test("workspace mark unread marks a read errored root agent", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: REPO_CWD,
|
||||
projectId: REPO_CWD,
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
});
|
||||
const markedAgentIds: string[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => emitted.push(message),
|
||||
agentManager: {
|
||||
markAgentUnread: async (agentId: string) => {
|
||||
markedAgentIds.push(agentId);
|
||||
},
|
||||
},
|
||||
});
|
||||
session.workspaceRegistry.get = async (id: string) =>
|
||||
id === workspace.workspaceId ? workspace : null;
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "errored-agent",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: workspace.workspaceId,
|
||||
status: "error",
|
||||
updatedAt: "2026-03-30T16:00:00.000Z",
|
||||
requiresAttention: false,
|
||||
}),
|
||||
];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.mark_unread.request",
|
||||
workspaceId: workspace.workspaceId,
|
||||
requestId: "req-mark-unread",
|
||||
});
|
||||
|
||||
expect(markedAgentIds).toEqual(["errored-agent"]);
|
||||
expect(findByType(emitted, "workspace.mark_unread.response").payload).toMatchObject({
|
||||
markedAgentId: "errored-agent",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace mark unread rejects workspaces without a read root agent", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: REPO_CWD,
|
||||
@@ -2150,7 +2196,7 @@ test("workspace mark unread rejects workspaces without a finished root agent", a
|
||||
workspaceId: workspace.workspaceId,
|
||||
markedAgentId: null,
|
||||
success: false,
|
||||
error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`,
|
||||
error: `Workspace has no read agent to mark unread: ${workspace.workspaceId}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -343,6 +343,21 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("an errored root agent shows failed only until its error is read", async () => {
|
||||
const unread = new WorkspaceStatus();
|
||||
unread.hasRootAgent({
|
||||
id: "root-agent",
|
||||
status: "error",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
await expect(unread.workspaceStatus()).resolves.toBe("failed");
|
||||
|
||||
const read = new WorkspaceStatus();
|
||||
read.hasRootAgent({ id: "root-agent", status: "error", requiresAttention: false });
|
||||
await expect(read.workspaceStatus()).resolves.toBe("done");
|
||||
});
|
||||
|
||||
test("a running background agent does not make its workspace running", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user