Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default defineConfig({
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/video-attachment.spec.ts",
Expand Down
19 changes: 13 additions & 6 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export function useAppNavigation() {
(
channelId: string,
options?: {
/** Open the agent activity pane for this agent pubkey on arrival. */
agentSession?: string;
messageId?: string;
replace?: boolean;
threadRootId?: string | null;
Expand All @@ -156,12 +158,17 @@ export function useAppNavigation() {
params: {
channelId,
},
search: options?.messageId
? {
messageId: options.messageId,
threadRootId: options.threadRootId ?? undefined,
}
: {},
search: {
...(options?.messageId
? {
messageId: options.messageId,
threadRootId: options.threadRootId ?? undefined,
}
: {}),
...(options?.agentSession
? { agentSession: options.agentSession }
: {}),
},
},
{
replace: options?.replace,
Expand Down
215 changes: 215 additions & 0 deletions desktop/src/features/agents/agentWorkingSignal.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";

import {
getAgentWorkingState,
getWorkingAgentPubkeysForChannel,
getWorkingChannels,
reportChannelBotTyping,
resetAgentWorkingSignal,
subscribeAgentWorkingSignal,
} from "./agentWorkingSignal.ts";
import {
resetActiveAgentTurnsStore,
syncAgentTurnsFromEvents,
} from "./activeAgentTurnsStore.ts";

const AGENT =
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
const AGENT_2 =
"dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321";

function makeEvent(overrides) {
return {
seq: 1,
timestamp: new Date().toISOString(),
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
...overrides,
};
}

function startTurn(agent, channelId, turnId = `turn-${channelId}`) {
syncAgentTurnsFromEvents(agent, [
makeEvent({ channelId, turnId, seq: Math.floor(Math.random() * 1e9) }),
]);
}

beforeEach(() => {
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
});

describe("getAgentWorkingState", () => {
it("is idle with no signals", () => {
const state = getAgentWorkingState(AGENT);
assert.equal(state.working, false);
assert.equal(state.source, "none");
assert.deepEqual(state.channels, []);
});

it("reports observer-backed work, unscoped (all-channels rule)", () => {
startTurn(AGENT, "chan-1");
const state = getAgentWorkingState(AGENT);
assert.equal(state.working, true);
assert.equal(state.source, "observer");
assert.deepEqual(
state.channels.map((c) => [c.channelId, c.source]),
[["chan-1", "observer"]],
);
});

it("scopes working to the requested channel", () => {
startTurn(AGENT, "chan-1");
const inChannel = getAgentWorkingState(AGENT, "chan-1");
assert.equal(inChannel.working, true);
assert.equal(inChannel.source, "observer");

const elsewhere = getAgentWorkingState(AGENT, "chan-2");
assert.equal(elsewhere.working, false);
assert.equal(elsewhere.source, "none");
// The unscoped channel list is still exposed for badges.
assert.equal(elsewhere.channels.length, 1);
});

it("falls back to typing when no observer turns exist", () => {
reportChannelBotTyping("chan-1", [AGENT]);
const state = getAgentWorkingState(AGENT, "chan-1");
assert.equal(state.working, true);
assert.equal(state.source, "typing");
assert.equal(state.channels[0].source, "typing");
assert.ok(state.channels[0].anchorAt <= Date.now());
});

it("prefers observer over typing for the same channel (no duplicate)", () => {
startTurn(AGENT, "chan-1");
reportChannelBotTyping("chan-1", [AGENT]);
const state = getAgentWorkingState(AGENT, "chan-1");
assert.equal(state.source, "observer");
assert.equal(state.channels.length, 1);
assert.equal(state.channels[0].source, "observer");
});

it("typing in one channel does not mark work in another", () => {
reportChannelBotTyping("chan-1", [AGENT]);
const state = getAgentWorkingState(AGENT, "chan-2");
assert.equal(state.working, false);
});

it("typing clears when re-reported empty", () => {
reportChannelBotTyping("chan-1", [AGENT]);
reportChannelBotTyping("chan-1", []);
assert.equal(getAgentWorkingState(AGENT, "chan-1").working, false);
});

it("preserves first-seen anchor across typing re-reports", async () => {
reportChannelBotTyping("chan-1", [AGENT]);
const first = getAgentWorkingState(AGENT).channels[0].anchorAt;
await new Promise((resolve) => setTimeout(resolve, 5));
reportChannelBotTyping("chan-1", [AGENT, AGENT_2]);
const again = getAgentWorkingState(AGENT).channels[0].anchorAt;
assert.equal(again, first);
});
});

describe("getWorkingChannels", () => {
it("merges typing-only agents into an observer channel summary", () => {
startTurn(AGENT, "chan-1");
reportChannelBotTyping("chan-1", [AGENT_2]);
const channels = getWorkingChannels();
assert.equal(channels.length, 1);
assert.equal(channels[0].source, "observer");
assert.equal(channels[0].agentCount, 2);
assert.deepEqual(
new Set(channels[0].agentPubkeys),
new Set([AGENT, AGENT_2]),
);
});

it("adds typing-only channels with a typing source", () => {
startTurn(AGENT, "chan-1");
reportChannelBotTyping("chan-2", [AGENT_2]);
const channels = getWorkingChannels();
assert.deepEqual(
channels.map((c) => [c.channelId, c.source]),
[
["chan-1", "observer"],
["chan-2", "typing"],
],
);
});
});

describe("getWorkingAgentPubkeysForChannel", () => {
it("unions observer and typing agents for the channel", () => {
startTurn(AGENT, "chan-1");
reportChannelBotTyping("chan-1", [AGENT_2]);
assert.deepEqual(
new Set(getWorkingAgentPubkeysForChannel("chan-1")),
new Set([AGENT, AGENT_2]),
);
assert.deepEqual(getWorkingAgentPubkeysForChannel("chan-2"), []);
assert.deepEqual(getWorkingAgentPubkeysForChannel(null), []);
});
});

describe("subscription and caching", () => {
it("returns reference-stable snapshots while subscribed", () => {
startTurn(AGENT, "chan-1");
const unsubscribe = subscribeAgentWorkingSignal(() => {});
try {
assert.equal(
getAgentWorkingState(AGENT, "chan-1"),
getAgentWorkingState(AGENT, "chan-1"),
);
assert.equal(getWorkingChannels(), getWorkingChannels());
assert.equal(
getWorkingAgentPubkeysForChannel("chan-1"),
getWorkingAgentPubkeysForChannel("chan-1"),
);
} finally {
unsubscribe();
}
});

it("notifies on typing changes but not on identical re-reports", () => {
let notified = 0;
const unsubscribe = subscribeAgentWorkingSignal(() => {
notified += 1;
});
try {
reportChannelBotTyping("chan-1", [AGENT]);
assert.equal(notified, 1);
reportChannelBotTyping("chan-1", [AGENT]);
assert.equal(notified, 1);
reportChannelBotTyping("chan-1", []);
assert.equal(notified, 2);
} finally {
unsubscribe();
}
});

it("invalidates snapshots when the turns store changes", () => {
const unsubscribe = subscribeAgentWorkingSignal(() => {});
try {
const before = getAgentWorkingState(AGENT, "chan-1");
assert.equal(before.working, false);
startTurn(AGENT, "chan-1");
const after = getAgentWorkingState(AGENT, "chan-1");
assert.equal(after.working, true);
assert.equal(after.source, "observer");
} finally {
unsubscribe();
}
});

it("resetAgentWorkingSignal clears typing state", () => {
reportChannelBotTyping("chan-1", [AGENT]);
resetAgentWorkingSignal();
assert.equal(getAgentWorkingState(AGENT, "chan-1").working, false);
});
});
Loading
Loading