fix: unify agent attention notification payloads (#55)
* fix: simplify tmux session creation in paseo.json * fix: unify agent attention notification payloads
This commit is contained in:
@@ -19,8 +19,12 @@ import type {
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||||
|
import {
|
||||||
|
buildAgentAttentionNotificationPayload,
|
||||||
|
type AgentAttentionNotificationPayload,
|
||||||
|
type NotificationPermissionRequest,
|
||||||
|
} from "@server/shared/agent-attention-notification";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useDaemonConnections } from "./daemon-connections-context";
|
import { useDaemonConnections } from "./daemon-connections-context";
|
||||||
@@ -52,7 +56,7 @@ export type {
|
|||||||
|
|
||||||
const derivePendingPermissionKey = (
|
const derivePendingPermissionKey = (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
request: AgentPermissionRequest
|
request: NotificationPermissionRequest
|
||||||
) => {
|
) => {
|
||||||
const fallbackId =
|
const fallbackId =
|
||||||
request.id ||
|
request.id ||
|
||||||
@@ -68,41 +72,8 @@ const derivePendingPermissionKey = (
|
|||||||
return `${agentId}:${fallbackId}`;
|
return `${agentId}:${fallbackId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NOTIFICATION_PREVIEW_LIMIT = 220;
|
|
||||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||||
|
|
||||||
const normalizeNotificationText = (text: string): string =>
|
|
||||||
text.replace(/\s+/g, " ").trim();
|
|
||||||
|
|
||||||
const truncateNotificationText = (text: string, limit: number): string => {
|
|
||||||
if (text.length <= limit) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const trimmed = text.slice(0, Math.max(0, limit - 3)).trimEnd();
|
|
||||||
return trimmed.length > 0 ? `${trimmed}...` : text.slice(0, limit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildNotificationPreview = (
|
|
||||||
text: string | null | undefined
|
|
||||||
): string | null => {
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalized = normalizeNotificationText(text);
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeStringify = (value: unknown): string | null => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
@@ -125,12 +96,12 @@ const mapConnectionState = (
|
|||||||
const getLatestPermissionRequest = (
|
const getLatestPermissionRequest = (
|
||||||
session: SessionState | undefined,
|
session: SessionState | undefined,
|
||||||
agentId: string
|
agentId: string
|
||||||
): AgentPermissionRequest | null => {
|
): NotificationPermissionRequest | null => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let latest: AgentPermissionRequest | null = null;
|
let latest: NotificationPermissionRequest | null = null;
|
||||||
for (const pending of session.pendingPermissions.values()) {
|
for (const pending of session.pendingPermissions.values()) {
|
||||||
if (pending.agentId === agentId) {
|
if (pending.agentId === agentId) {
|
||||||
latest = pending.request;
|
latest = pending.request;
|
||||||
@@ -142,46 +113,12 @@ const getLatestPermissionRequest = (
|
|||||||
|
|
||||||
const agentPending = session.agents.get(agentId)?.pendingPermissions;
|
const agentPending = session.agents.get(agentId)?.pendingPermissions;
|
||||||
if (agentPending && agentPending.length > 0) {
|
if (agentPending && agentPending.length > 0) {
|
||||||
return agentPending[agentPending.length - 1] as AgentPermissionRequest;
|
return agentPending[agentPending.length - 1] as NotificationPermissionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPermissionDetails = (
|
|
||||||
request: AgentPermissionRequest | null
|
|
||||||
): string | null => {
|
|
||||||
if (!request) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const title = request.title?.trim();
|
|
||||||
const description = request.description?.trim();
|
|
||||||
const details: string[] = [];
|
|
||||||
if (title) {
|
|
||||||
details.push(title);
|
|
||||||
}
|
|
||||||
if (description && description !== title) {
|
|
||||||
details.push(description);
|
|
||||||
}
|
|
||||||
if (details.length > 0) {
|
|
||||||
return details.join(" - ");
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputPreview = request.input ? safeStringify(request.input) : null;
|
|
||||||
if (inputPreview) {
|
|
||||||
return inputPreview;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataPreview = request.metadata
|
|
||||||
? safeStringify(request.metadata)
|
|
||||||
: null;
|
|
||||||
if (metadataPreview) {
|
|
||||||
return metadataPreview;
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.name?.trim() || request.kind;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileExplorerPayload = Extract<
|
type FileExplorerPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "file_explorer_response" }
|
{ type: "file_explorer_response" }
|
||||||
@@ -403,6 +340,7 @@ export function SessionProvider({
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
reason: "finished" | "error" | "permission";
|
reason: "finished" | "error" | "permission";
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
notification?: AgentAttentionNotificationPayload;
|
||||||
}) => {
|
}) => {
|
||||||
const appState = appStateRef.current;
|
const appState = appStateRef.current;
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
@@ -423,43 +361,33 @@ export function SessionProvider({
|
|||||||
}
|
}
|
||||||
attentionNotifiedRef.current.set(params.agentId, timestampMs);
|
attentionNotifiedRef.current.set(params.agentId, timestampMs);
|
||||||
|
|
||||||
const title =
|
const head = session?.agentStreamHead.get(params.agentId) ?? [];
|
||||||
params.reason === "permission"
|
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
|
||||||
? "Agent needs permission"
|
const assistantMessage =
|
||||||
: "Agent finished";
|
findLatestAssistantMessageText(head) ??
|
||||||
let preview: string | null = null;
|
findLatestAssistantMessageText(tail);
|
||||||
|
const permissionRequest = getLatestPermissionRequest(
|
||||||
|
session,
|
||||||
|
params.agentId
|
||||||
|
);
|
||||||
|
|
||||||
if (params.reason === "finished") {
|
const notification =
|
||||||
const head = session?.agentStreamHead.get(params.agentId) ?? [];
|
params.notification ??
|
||||||
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
|
buildAgentAttentionNotificationPayload({
|
||||||
const lastMessage =
|
reason: params.reason,
|
||||||
findLatestAssistantMessageText(head) ??
|
serverId,
|
||||||
findLatestAssistantMessageText(tail);
|
agentId: params.agentId,
|
||||||
preview = buildNotificationPreview(lastMessage);
|
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
||||||
} else if (params.reason === "permission") {
|
permissionRequest:
|
||||||
const permissionRequest = getLatestPermissionRequest(
|
params.reason === "permission"
|
||||||
session,
|
? permissionRequest
|
||||||
params.agentId
|
: null,
|
||||||
);
|
});
|
||||||
preview = buildNotificationPreview(
|
|
||||||
buildPermissionDetails(permissionRequest)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body =
|
|
||||||
preview ??
|
|
||||||
(params.reason === "permission"
|
|
||||||
? "Permission requested."
|
|
||||||
: "Finished working.");
|
|
||||||
|
|
||||||
void sendOsNotification({
|
void sendOsNotification({
|
||||||
title,
|
title: notification.title,
|
||||||
body,
|
body: notification.body,
|
||||||
data: {
|
data: notification.data,
|
||||||
agentId: params.agentId,
|
|
||||||
serverId,
|
|
||||||
reason: params.reason,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[serverId]
|
[serverId]
|
||||||
@@ -879,7 +807,7 @@ export function SessionProvider({
|
|||||||
const nextAgents = new Map<string, Agent>();
|
const nextAgents = new Map<string, Agent>();
|
||||||
const nextPendingPermissions = new Map<
|
const nextPendingPermissions = new Map<
|
||||||
string,
|
string,
|
||||||
{ key: string; agentId: string; request: AgentPermissionRequest }
|
{ key: string; agentId: string; request: NotificationPermissionRequest }
|
||||||
>();
|
>();
|
||||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||||
|
|
||||||
@@ -971,6 +899,7 @@ export function SessionProvider({
|
|||||||
agentId,
|
agentId,
|
||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
timestamp: event.timestamp,
|
timestamp: event.timestamp,
|
||||||
|
notification: event.notification,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const wsModuleMock = vi.hoisted(() => {
|
||||||
|
class MockWebSocketServer {
|
||||||
|
readonly handlers = new Map<string, (...args: any[]) => void>();
|
||||||
|
|
||||||
|
constructor(_options: unknown) {}
|
||||||
|
|
||||||
|
on(event: string, handler: (...args: any[]) => void) {
|
||||||
|
this.handlers.set(event, handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { MockWebSocketServer };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushMocks = vi.hoisted(() => ({
|
||||||
|
getAllTokens: vi.fn(() => ["ExponentPushToken[token-1]"]),
|
||||||
|
sendPush: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("ws", () => ({
|
||||||
|
WebSocketServer: wsModuleMock.MockWebSocketServer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./session.js", () => ({
|
||||||
|
Session: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./push/token-store.js", () => ({
|
||||||
|
PushTokenStore: class {
|
||||||
|
getAllTokens = pushMocks.getAllTokens;
|
||||||
|
removeToken = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./push/push-service.js", () => ({
|
||||||
|
PushService: class {
|
||||||
|
sendPush = pushMocks.sendPush;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
const logger = {
|
||||||
|
child: vi.fn(() => logger),
|
||||||
|
trace: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||||
|
const agentManager = {
|
||||||
|
setAgentAttentionCallback: vi.fn(),
|
||||||
|
getAgent: vi.fn(() => null),
|
||||||
|
...agentManagerOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new VoiceAssistantWebSocketServer(
|
||||||
|
{} as any,
|
||||||
|
createLogger() as any,
|
||||||
|
"srv-test",
|
||||||
|
agentManager as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
"/tmp/paseo-test",
|
||||||
|
async () => ({} as any),
|
||||||
|
{ allowedOrigins: new Set() }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { server, agentManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses assistant preview text for push notifications with markdown removed", () => {
|
||||||
|
const { server } = createServer({
|
||||||
|
getAgent: vi.fn(() => ({
|
||||||
|
config: { title: null },
|
||||||
|
cwd: "/tmp/worktree",
|
||||||
|
timeline: [
|
||||||
|
{
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
(server as any).broadcastAgentAttention({
|
||||||
|
agentId: "agent-1",
|
||||||
|
provider: "claude",
|
||||||
|
reason: "finished",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushMocks.sendPush).toHaveBeenCalledWith(
|
||||||
|
["ExponentPushToken[token-1]"],
|
||||||
|
{
|
||||||
|
title: "Agent finished",
|
||||||
|
body: "Done. Updated README.md and link.",
|
||||||
|
data: {
|
||||||
|
serverId: "srv-test",
|
||||||
|
agentId: "agent-1",
|
||||||
|
reason: "finished",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,11 @@ import {
|
|||||||
computeShouldSendPush,
|
computeShouldSendPush,
|
||||||
type ClientAttentionState,
|
type ClientAttentionState,
|
||||||
} from "./agent-attention-policy.js";
|
} from "./agent-attention-policy.js";
|
||||||
|
import {
|
||||||
|
buildAgentAttentionNotificationPayload,
|
||||||
|
findLatestAssistantMessageFromTimeline,
|
||||||
|
findLatestPermissionRequest,
|
||||||
|
} from "../shared/agent-attention-notification.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
export type ExternalSocketMetadata = {
|
export type ExternalSocketMetadata = {
|
||||||
@@ -819,6 +824,18 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allStates = clientEntries.map((e) => e.state);
|
const allStates = clientEntries.map((e) => e.state);
|
||||||
|
const agent = this.agentManager.getAgent(params.agentId);
|
||||||
|
const notification = buildAgentAttentionNotificationPayload({
|
||||||
|
reason: params.reason,
|
||||||
|
serverId: this.serverId,
|
||||||
|
agentId: params.agentId,
|
||||||
|
assistantMessage: agent
|
||||||
|
? findLatestAssistantMessageFromTimeline(agent.timeline)
|
||||||
|
: null,
|
||||||
|
permissionRequest: agent
|
||||||
|
? findLatestPermissionRequest(agent.pendingPermissions)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Push is only a fallback when the user is away from desktop/web.
|
// Push is only a fallback when the user is away from desktop/web.
|
||||||
// Also suppress push if they're actively using the mobile app.
|
// Also suppress push if they're actively using the mobile app.
|
||||||
@@ -831,24 +848,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const tokens = this.pushTokenStore.getAllTokens();
|
const tokens = this.pushTokenStore.getAllTokens();
|
||||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||||
if (tokens.length > 0) {
|
if (tokens.length > 0) {
|
||||||
const agent = this.agentManager.getAgent(params.agentId);
|
void this.pushService.sendPush(tokens, notification);
|
||||||
const agentTitle = agent?.config?.title ?? agent?.cwd ?? params.agentId;
|
|
||||||
const title =
|
|
||||||
params.reason === "permission" ? "Agent needs permission" : "Agent finished";
|
|
||||||
const body =
|
|
||||||
params.reason === "permission"
|
|
||||||
? `Permission requested: ${agentTitle}`
|
|
||||||
: `Finished: ${agentTitle}`;
|
|
||||||
|
|
||||||
void this.pushService.sendPush(tokens, {
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
data: {
|
|
||||||
serverId: this.serverId,
|
|
||||||
agentId: params.agentId,
|
|
||||||
reason: params.reason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,6 +869,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
shouldNotify,
|
shouldNotify,
|
||||||
|
notification,
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildAgentAttentionNotificationPayload,
|
||||||
|
findLatestAssistantMessageFromTimeline,
|
||||||
|
findLatestPermissionRequest,
|
||||||
|
} from "./agent-attention-notification.js";
|
||||||
|
|
||||||
|
describe("buildAgentAttentionNotificationPayload", () => {
|
||||||
|
it("builds finished notifications from markdown assistant text", () => {
|
||||||
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
|
reason: "finished",
|
||||||
|
serverId: "srv-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
assistantMessage:
|
||||||
|
"**Done**. Updated `README.md` and [link](https://example.com).",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
title: "Agent finished",
|
||||||
|
body: "Done. Updated README.md and link.",
|
||||||
|
data: {
|
||||||
|
serverId: "srv-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
reason: "finished",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds permission notifications from request details", () => {
|
||||||
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
|
reason: "permission",
|
||||||
|
serverId: "srv-2",
|
||||||
|
agentId: "agent-2",
|
||||||
|
permissionRequest: {
|
||||||
|
id: "perm-1",
|
||||||
|
provider: "claude",
|
||||||
|
name: "exec",
|
||||||
|
kind: "tool",
|
||||||
|
title: "**Approve command**",
|
||||||
|
description: "Run `git push`",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
title: "Agent needs permission",
|
||||||
|
body: "Approve command - Run git push",
|
||||||
|
data: {
|
||||||
|
serverId: "srv-2",
|
||||||
|
agentId: "agent-2",
|
||||||
|
reason: "permission",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses error-specific defaults when reason is error", () => {
|
||||||
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
|
reason: "error",
|
||||||
|
serverId: "srv-3",
|
||||||
|
agentId: "agent-3",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
title: "Agent needs attention",
|
||||||
|
body: "Encountered an error.",
|
||||||
|
data: {
|
||||||
|
serverId: "srv-3",
|
||||||
|
agentId: "agent-3",
|
||||||
|
reason: "error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findLatestAssistantMessageFromTimeline", () => {
|
||||||
|
it("joins the latest contiguous assistant chunks", () => {
|
||||||
|
expect(
|
||||||
|
findLatestAssistantMessageFromTimeline([
|
||||||
|
{ type: "user_message", text: "start" },
|
||||||
|
{ type: "assistant_message", text: "Part " },
|
||||||
|
{ type: "assistant_message", text: "one" },
|
||||||
|
{ type: "reasoning", text: "thinking..." },
|
||||||
|
{ type: "assistant_message", text: "Done " },
|
||||||
|
{ type: "assistant_message", text: "now" },
|
||||||
|
])
|
||||||
|
).toBe("Done now");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findLatestPermissionRequest", () => {
|
||||||
|
it("returns the most recently inserted request", () => {
|
||||||
|
const pending = new Map([
|
||||||
|
[
|
||||||
|
"first",
|
||||||
|
{ id: "first", provider: "claude", name: "a", kind: "tool" } as const,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"second",
|
||||||
|
{ id: "second", provider: "claude", name: "b", kind: "tool" } as const,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(findLatestPermissionRequest(pending)?.id).toBe("second");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const NOTIFICATION_PREVIEW_LIMIT = 220;
|
||||||
|
|
||||||
|
export type AgentAttentionReason = "finished" | "error" | "permission";
|
||||||
|
|
||||||
|
export type AgentAttentionNotificationData = {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
reason: AgentAttentionReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentAttentionNotificationPayload = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data: AgentAttentionNotificationData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildAgentAttentionNotificationPayloadInput = {
|
||||||
|
reason: AgentAttentionReason;
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
assistantMessage?: string | null;
|
||||||
|
permissionRequest?: NotificationPermissionRequest | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationPermissionRequest = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
kind: "tool" | "plan" | "question" | "mode" | "other";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistantTimelineItem =
|
||||||
|
| { type: "assistant_message"; text: string }
|
||||||
|
| { type: string; text?: string };
|
||||||
|
|
||||||
|
const normalizeNotificationText = (text: string): string =>
|
||||||
|
text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
const truncateNotificationText = (text: string, limit: number): string => {
|
||||||
|
if (text.length <= limit) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const trimmed = text.slice(0, Math.max(0, limit - 3)).trimEnd();
|
||||||
|
return trimmed.length > 0 ? `${trimmed}...` : text.slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripMarkdownToText = (markdown: string): string => {
|
||||||
|
let text = markdown.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
|
// Strip fenced code markers but keep the code content itself.
|
||||||
|
text = text.replace(/^\s*```[^\n]*$/gm, "");
|
||||||
|
text = text.replace(/^\s*~~~[^\n]*$/gm, "");
|
||||||
|
|
||||||
|
// Markdown links/images.
|
||||||
|
text = text.replace(/!\[([^\]]*)\]\((?:[^()\\]|\\.)*\)/g, "$1");
|
||||||
|
text = text.replace(/\[([^\]]+)\]\((?:[^()\\]|\\.)*\)/g, "$1");
|
||||||
|
|
||||||
|
// Structural prefixes.
|
||||||
|
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, "");
|
||||||
|
text = text.replace(/^\s{0,3}>+\s?/gm, "");
|
||||||
|
text = text.replace(/^\s{0,3}(?:[*+-]|\d+\.)\s+/gm, "");
|
||||||
|
text = text.replace(/^\s{0,3}(?:[-*_]\s*){3,}$/gm, "");
|
||||||
|
|
||||||
|
// Inline markdown markers.
|
||||||
|
text = text.replace(/`([^`]+)`/g, "$1");
|
||||||
|
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
||||||
|
text = text.replace(/__([^_]+)__/g, "$1");
|
||||||
|
text = text.replace(/\*([^*\n]+)\*/g, "$1");
|
||||||
|
text = text.replace(/_([^_\n]+)_/g, "$1");
|
||||||
|
text = text.replace(/~~([^~]+)~~/g, "$1");
|
||||||
|
|
||||||
|
// Angle-bracketed URL autolinks.
|
||||||
|
text = text.replace(/<([^>\n]+)>/g, "$1");
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNotificationPreview = (
|
||||||
|
text: string | null | undefined
|
||||||
|
): string | null => {
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeNotificationText(stripMarkdownToText(text));
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeStringify = (value: unknown): string | null => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPermissionDetails = (
|
||||||
|
request: NotificationPermissionRequest | null | undefined
|
||||||
|
): string | null => {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = request.title?.trim();
|
||||||
|
const description = request.description?.trim();
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
details.push(title);
|
||||||
|
}
|
||||||
|
if (description && description !== title) {
|
||||||
|
details.push(description);
|
||||||
|
}
|
||||||
|
if (details.length > 0) {
|
||||||
|
return details.join(" - ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPreview = request.input ? safeStringify(request.input) : null;
|
||||||
|
if (inputPreview) {
|
||||||
|
return inputPreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataPreview = request.metadata ? safeStringify(request.metadata) : null;
|
||||||
|
if (metadataPreview) {
|
||||||
|
return metadataPreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.name?.trim() || request.kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function findLatestAssistantMessageFromTimeline(
|
||||||
|
timeline: readonly AssistantTimelineItem[]
|
||||||
|
): string | null {
|
||||||
|
// Providers may stream assistant content in consecutive chunks.
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = timeline.length - 1; i >= 0; i -= 1) {
|
||||||
|
const item = timeline[i];
|
||||||
|
if (item.type !== "assistant_message" || typeof item.text !== "string") {
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.push(item.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.reverse().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLatestPermissionRequest(
|
||||||
|
pendingPermissions: ReadonlyMap<string, NotificationPermissionRequest>
|
||||||
|
): NotificationPermissionRequest | null {
|
||||||
|
let latest: NotificationPermissionRequest | null = null;
|
||||||
|
for (const request of pendingPermissions.values()) {
|
||||||
|
latest = request;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentAttentionNotificationPayload(
|
||||||
|
input: BuildAgentAttentionNotificationPayloadInput
|
||||||
|
): AgentAttentionNotificationPayload {
|
||||||
|
const title =
|
||||||
|
input.reason === "permission"
|
||||||
|
? "Agent needs permission"
|
||||||
|
: input.reason === "error"
|
||||||
|
? "Agent needs attention"
|
||||||
|
: "Agent finished";
|
||||||
|
|
||||||
|
const preview =
|
||||||
|
input.reason === "finished"
|
||||||
|
? buildNotificationPreview(input.assistantMessage)
|
||||||
|
: input.reason === "permission"
|
||||||
|
? buildNotificationPreview(buildPermissionDetails(input.permissionRequest))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const body =
|
||||||
|
preview ??
|
||||||
|
(input.reason === "permission"
|
||||||
|
? "Permission requested."
|
||||||
|
: input.reason === "error"
|
||||||
|
? "Encountered an error."
|
||||||
|
: "Finished working.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
reason: input.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -332,6 +332,17 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
|
|||||||
reason: z.enum(["finished", "error", "permission"]),
|
reason: z.enum(["finished", "error", "permission"]),
|
||||||
timestamp: z.string(),
|
timestamp: z.string(),
|
||||||
shouldNotify: z.boolean(),
|
shouldNotify: z.boolean(),
|
||||||
|
notification: z
|
||||||
|
.object({
|
||||||
|
title: z.string(),
|
||||||
|
body: z.string(),
|
||||||
|
data: z.object({
|
||||||
|
serverId: z.string(),
|
||||||
|
agentId: z.string(),
|
||||||
|
reason: z.enum(["finished", "error", "permission"]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"terminals": [
|
"terminals": [
|
||||||
{
|
{
|
||||||
"name": "dev",
|
"name": "dev",
|
||||||
"command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi"
|
"command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\" \"npm run dev\"; fi"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user