fix: clear stale workspace activity indicators

This commit is contained in:
Federico Jaramillo Martinez
2026-05-24 08:15:47 +02:00
parent 56fa641a8d
commit 679008d132
5 changed files with 128 additions and 11 deletions
+66 -1
View File
@@ -1,5 +1,5 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js";
@@ -148,6 +148,71 @@ describe("PiSessionService", () => {
expect(fake.calls.dispose).toBe(1);
});
it("clears stale active activity once a previously active session becomes idle", async () => {
vi.useFakeTimers();
let service: PiSessionService | undefined;
try {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("idle-session", {
isStreaming: true,
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
});
await service.status("idle-session");
hub.globalEvents.length = 0;
listener?.({ type: "agent_start" });
const activityPhases = () => hub.globalEvents
.filter((event) => event.type === "activity.update")
.map((event) => event.activity.phase);
expect(activityPhases()).toEqual(["active"]);
fake.session.isStreaming = false;
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(activityPhases()).toEqual(["active", "idle"]);
} finally {
await service?.dispose();
vi.useRealTimers();
}
});
it("publishes idle activity for SDK completion events", async () => {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("completion-session", {
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status("completion-session");
hub.globalEvents.length = 0;
listener?.({ type: "tool_execution_end", toolName: "read", isError: false });
expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([
{ activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } },
]);
await service.dispose();
});
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
+21 -7
View File
@@ -674,10 +674,12 @@ export class PiSessionService {
for (const active of this.active.values()) {
const { session } = active.runtime;
const activity = this.activities.get(session.sessionId);
const isActive = session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0 || activity?.phase === "active";
if (!isActive) continue;
if (!sessionHasActiveWork(session)) {
if (activity?.phase === "active") this.publishStatus(session);
continue;
}
this.publishStatus(session);
if (activity) this.publishActivity(session, activity.label, "active", activity.detail);
if (activity?.phase === "active") this.publishActivity(session, activity.label, "active", activity.detail);
else this.publishActivity(session, this.activityLabelFromStatus(session), "active");
}
}
@@ -702,19 +704,19 @@ export class PiSessionService {
}, 250);
return;
}
if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; }
if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "idle"); return; }
if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; }
if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; }
if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; }
if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; }
if (eventType === "tool_execution_end") {
const isError = getBoolean(event, "isError") === true;
this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName"));
this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "idle", getString(event, "toolName"));
return;
}
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; }
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "idle"); return; }
if (sessionHasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
}
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
@@ -729,11 +731,23 @@ export class PiSessionService {
private publishStatus(session: PiAgentSession): void {
const status = this.statusFromSession(session);
this.clearStaleActiveActivity(session);
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
this.events.publish(session.sessionId, { type: "status.update", status });
this.events.publishGlobal({ type: "status.update", status });
}
private clearStaleActiveActivity(session: PiAgentSession): void {
const current = this.activities.get(session.sessionId);
if (current?.phase !== "active" || sessionHasActiveWork(session)) return;
const at = new Date().toISOString();
const stored = { phase: "idle" as const, label: "idle", at };
this.activities.set(session.sessionId, stored);
const activity = { sessionId: session.sessionId, ...stored };
this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity });
}
private statusFromSession(session: PiAgentSession): ClientSessionStatus {
const stats = session.getSessionStats();
const model = session.model === undefined ? undefined : modelToClientModel(session.model);