Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ describe("AgentAwarenessRelay", () => {
"run.created",
"run.updated",
"runtime-request.updated",
// Pending background work changes can release a held completion.
"subagent.updated",
"provider-thread.updated",
"thread.metadata-updated",
"thread.model-selection-updated",
"thread.provider-switched",
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/relay/AgentAwarenessRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ export function shouldPublishAgentAwarenessEvent(
) {
return false;
}
// projectThreadAwarenessV2 reads thread metadata, run status, and pending requests.
// Message bodies and tool progress cannot change the published activity.
// projectThreadAwarenessV2 reads thread metadata, run status, pending requests,
// and pending background work (a finished subagent or a cleared roster can
// release a held completion). Message bodies and tool progress cannot change
// the published activity.
switch (event.type) {
case "thread.created":
case "thread.archived":
Expand All @@ -90,6 +92,8 @@ export function shouldPublishAgentAwarenessEvent(
case "run.created":
case "run.updated":
case "runtime-request.updated":
case "subagent.updated":
case "provider-thread.updated":
return true;
case "thread.settled":
case "thread.unsettled":
Expand All @@ -108,11 +112,9 @@ export function shouldPublishAgentAwarenessEvent(
case "run-attempt.created":
case "run-attempt.updated":
case "node.updated":
case "subagent.updated":
case "provider-session.attached":
case "provider-session.updated":
case "provider-session.detached":
case "provider-thread.updated":
case "provider-turn.updated":
case "message.updated":
case "turn-item.updated":
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/ThreadNotificationCoordinator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const state = vi.hoisted(() => ({
turnError: false,
limited: false,
subagent: false,
background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,
add: vi.fn(
(_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
"toast-1",
Expand Down Expand Up @@ -70,6 +71,7 @@ function mockThreadShell() {
latestVisibleMessage: null,
latestUserMessageAt: null,
hasActionableProposedPlan: false,
pendingBackgroundTasks: state.background,
itemCount: 0,
visibleItemCount: 0,
createdAt: SHELL_NOW,
Expand Down Expand Up @@ -151,6 +153,7 @@ beforeEach(() => {
turnError: false,
limited: false,
subagent: false,
background: [],
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", new EventTarget());
Expand Down Expand Up @@ -248,6 +251,19 @@ describe("thread notifications", () => {
});
});

it("alerts when only a dev server is left running, not while a monitor can wake the agent", async () => {
await render();
state.background = [{ taskId: "watch", kind: "monitor" }];
await complete();
expect(state.add).not.toHaveBeenCalled();
state.background = [{ taskId: "dev", kind: "command" }];
await render();
expect(state.add).toHaveBeenCalledTimes(1);
expect(state.add).toHaveBeenLastCalledWith(
expect.objectContaining({ title: "Thread completed" }),
);
});

it("keeps background desktop alerts when in-app notifications are disabled", async () => {
state.focused = false;
state.inApp = false;
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import { useAtomValue } from "@effect/atom-react";
import { useNavigate, useParams } from "@tanstack/react-router";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import * as Option from "effect/Option";
import {
CircleAlertIcon,
Expand Down Expand Up @@ -125,10 +126,12 @@ function EnvironmentNotifications({
? `${thread.latestRun?.runId ?? ""}:${status}`
: null;
const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");
// Waiting only on commands (a dev server) is done; subagents and monitors wake the agent.
const settled =
status === "ready" ||
(status === "waiting" && !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks));
const completion =
status === "ready" &&
thread.latestRun?.status === "completed" &&
Number.isFinite(completedAt)
settled && thread.latestRun?.status === "completed" && Number.isFinite(completedAt)
? completedAt
: (prior?.completion ?? null);
next.set(thread.id, { attention, completion });
Expand Down
27 changes: 26 additions & 1 deletion packages/shared/src/agentAwareness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ describe("projectThreadAwarenessV2", () => {
overrides: Partial<
Pick<
OrchestrationV2ThreadShell,
"activityRunStatus" | "status" | "pendingRuntimeRequest" | "lineage"
| "activityRunStatus"
| "status"
| "pendingBackgroundTasks"
| "pendingRuntimeRequest"
| "lineage"
>
> = {},
) => ({
Expand Down Expand Up @@ -81,6 +85,27 @@ describe("projectThreadAwarenessV2", () => {
).toMatchObject({ phase: "running", headline: "Agent is working" });
});

it.each([
["only a dev server", "completed", [{ taskId: "dev", kind: "command" }]],
["a monitor", "running", [{ taskId: "watch", kind: "monitor" }]],
[
"a dev server and a subagent",
"running",
[
{ taskId: "dev", kind: "command" },
{ taskId: "review", kind: "subagent" },
],
],
] as const)("reports a completed run waiting on %s as %s", (_case, phase, tasks) => {
expect(
projectThreadAwarenessV2({
environmentId: "env-1" as EnvironmentId,
project,
thread: v2Thread({ status: "completed", pendingBackgroundTasks: tasks }),
}),
).toMatchObject({ phase });
});

it("prioritizes V2 user-input requests", () => {
expect(
projectThreadAwarenessV2({
Expand Down
8 changes: 7 additions & 1 deletion packages/shared/src/agentAwareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";

import { backgroundWorkHoldsCompletion } from "./orchestrationV2PendingBackgroundWork.ts";

export type AgentAwarenessPhase =
| "starting"
| "running"
Expand Down Expand Up @@ -44,6 +46,7 @@ export interface ProjectThreadAwarenessV2Input {
| "id"
| "lineage"
| "modelSelection"
| "pendingBackgroundTasks"
| "pendingRuntimeRequest"
| "status"
| "title"
Expand Down Expand Up @@ -101,7 +104,10 @@ function resolveThreadAwarenessPhaseV2(
case "waiting":
return "running";
case "completed":
return "completed";
// Work that will wake the agent keeps the run going; a dev server does not.
return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
? "running"
: "completed";
case "failed":
return "failed";
case "idle":
Expand Down
27 changes: 26 additions & 1 deletion packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { describe, expect, it } from "vite-plus/test";
import { derivePendingBackgroundWork } from "./orchestrationV2PendingBackgroundWork.ts";
import type { OrchestrationV2PendingBackgroundTask } from "@t3tools/contracts";
import {
backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
} from "./orchestrationV2PendingBackgroundWork.ts";

describe("backgroundWorkHoldsCompletion", () => {
const task = (
taskId: string,
kind: OrchestrationV2PendingBackgroundTask["kind"],
): OrchestrationV2PendingBackgroundTask => ({ taskId, kind });

it.each([
["nothing pending", false, []],
["a dev server", false, [task("dev", "command")]],
["two long-lived shells", false, [task("web", "command"), task("api", "command")]],
["a subagent", true, [task("review", "subagent")]],
["a monitor", true, [task("watch", "monitor")]],
["a command and a monitor", true, [task("dev", "command"), task("watch", "monitor")]],
["a command and a subagent", true, [task("dev", "command"), task("review", "subagent")]],
// Also what an unknown or missing kind decodes to.
["work the provider cannot name", true, [task("opaque", "background_task")]],
] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => {
expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds);
});
});

describe("derivePendingBackgroundWork", () => {
it("returns empty while the latest run is not settled", () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/orchestrationV2PendingBackgroundWork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ void TERMINAL_RUN_STATUSES;

export type PendingBackgroundWorkTask = OrchestrationV2PendingBackgroundTask;

/**
* Whether background work left behind by a completed root run holds back its
* completion alert (desktop/web notification and the mobile push). Commands,
* such as dev servers and other long-lived shells, do not: the agent is done
* and may leave them running for hours. Subagents and monitors do, because
* they wake the agent and it continues (#13625). Work the adapter cannot name,
* including kinds this build does not know, holds as the conservative choice.
*/
export function backgroundWorkHoldsCompletion(
tasks: ReadonlyArray<Pick<PendingBackgroundWorkTask, "kind">>,
): boolean {
return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind));
}

function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean {
switch (kind) {
case "command":
return false;
case "subagent":
case "monitor":
case "background_task":
return true;
}
}

type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">;

type PendingBackgroundWorkProviderThread = Pick<
Expand Down
Loading