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
40 changes: 40 additions & 0 deletions desktop/src/features/agents/agentWorkingSignal.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, it } from "node:test";

import {
getAgentWorkingState,
getWorkingAgentPubkeysForConversation,
getWorkingAgentPubkeysForChannel,
getWorkingChannels,
reportChannelBotTyping,
Expand Down Expand Up @@ -76,6 +77,45 @@ describe("getAgentWorkingState", () => {
assert.equal(elsewhere.channels.length, 1);
});

it("keeps thread turns visible through the parent channel scope", () => {
startTurn(AGENT, "chan-1", "thread-turn");
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
channelId: "chan-1",
conversationId: "thread-conversation",
turnId: "thread-turn",
seq: 2,
}),
]);

assert.deepEqual(getWorkingAgentPubkeysForChannel("chan-1"), [AGENT]);
assert.equal(getAgentWorkingState(AGENT, "chan-1").working, true);
});

it("scopes thread activity by the derived conversation identity", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
channelId: "chan-1",
conversationId: "thread-a",
turnId: "thread-a-turn",
}),
]);
syncAgentTurnsFromEvents(AGENT_2, [
makeEvent({
channelId: "chan-1",
conversationId: "thread-b",
turnId: "thread-b-turn",
}),
]);

assert.deepEqual(getWorkingAgentPubkeysForConversation("thread-a"), [
AGENT,
]);
assert.deepEqual(getWorkingAgentPubkeysForConversation("thread-b"), [
AGENT_2,
]);
});

it("falls back to typing when no observer turns exist", () => {
reportChannelBotTyping("chan-1", [AGENT]);
const state = getAgentWorkingState(AGENT, "chan-1");
Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/agents/agentWorkingSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import {
type ActiveChannelTurnSummary,
getActiveTurnsByChannel,
getActiveAgentsForConversation,
getActiveTurnsForAgent,
subscribeActiveAgentTurns,
} from "./activeAgentTurnsStore";
Expand Down Expand Up @@ -296,6 +297,18 @@ export function getWorkingAgentPubkeysForChannel(
return result;
}

export function mergeWorkingAgentPubkeys(
...pubkeyLists: readonly (readonly string[])[]
): string[] {
const merged = new Set<string>();
for (const pubkeyList of pubkeyLists) {
for (const pubkey of pubkeyList) {
merged.add(normalizePubkey(pubkey));
}
}
return merged.size === 0 ? EMPTY_PUBKEYS : [...merged];
}

// ── Hooks ────────────────────────────────────────────────────────────────────

/** Working state for one agent, optionally scoped to a channel. */
Expand Down Expand Up @@ -325,6 +338,27 @@ export function useChannelWorkingAgentPubkeys(
);
}

/** Normalized pubkeys of agents working in a conversation. */
export function useConversationWorkingAgentPubkeys(
conversationId: string | null | undefined,
fallbackPubkeys: readonly string[] = EMPTY_PUBKEYS,
): string[] {
const observerPubkeys = React.useSyncExternalStore(
subscribeAgentWorkingSignal,
() => getWorkingAgentPubkeysForConversation(conversationId),
);
return React.useMemo(
() => mergeWorkingAgentPubkeys(observerPubkeys, fallbackPubkeys),
[fallbackPubkeys, observerPubkeys],
);
}

export function getWorkingAgentPubkeysForConversation(
conversationId: string | null | undefined,
): string[] {
return getActiveAgentsForConversation(conversationId);
}

/** Community-switch reset (see resetCommunityState in useCommunityInit). */
export function resetAgentWorkingSignal() {
typingByChannel.clear();
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/features/agents/conversationId.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
deriveAgentConversationId,
deriveAgentConversationIdOrNull,
} from "./conversationId.ts";

describe("deriveAgentConversationId", () => {
it("matches Rust conversation identity vectors", () => {
assert.equal(
deriveAgentConversationId(
"00112233-4455-6677-8899-aabbccddeeff",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
),
"7415ce56-7adc-d430-f133-c5e06a8e5113",
);
assert.equal(
deriveAgentConversationId(
"11111111-2222-3333-4444-555555555555",
"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
),
"026dfba8-bd95-7847-6709-920a0e6d9b97",
);
});

it("returns null for malformed channel or root IDs", () => {
assert.equal(
deriveAgentConversationIdOrNull(
"not-a-uuid",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
),
null,
);
assert.equal(
deriveAgentConversationIdOrNull(
"00112233-4455-6677-8899-aabbccddeeff",
"not-an-event-id",
),
null,
);
assert.equal(deriveAgentConversationIdOrNull(null, null), null);
});
});
56 changes: 56 additions & 0 deletions desktop/src/features/agents/conversationId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { sha256 } from "@noble/hashes/sha2.js";

const CONVERSATION_DOMAIN = new TextEncoder().encode(
"buzz-acp-conversation-v1",
);

function decodeUuid(uuid: string): Uint8Array {
const hex = uuid.replaceAll("-", "");
if (!/^[0-9a-f]{32}$/i.test(hex)) {
throw new Error(`Invalid UUID: ${uuid}`);
}
return Uint8Array.from({ length: 16 }, (_, index) =>
Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16),
);
}

function formatUuid(bytes: Uint8Array): string {
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return [
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10, 16).join(""),
].join("-");
}

export function deriveAgentConversationId(
channelId: string,
rootEventId: string,
): string {
if (!/^[0-9a-f]{64}$/.test(rootEventId)) {
throw new Error(`Invalid root event ID: ${rootEventId}`);
}
const channelBytes = decodeUuid(channelId);
const rootBytes = new TextEncoder().encode(rootEventId);
const input = new Uint8Array(
CONVERSATION_DOMAIN.length + channelBytes.length + rootBytes.length,
);
input.set(CONVERSATION_DOMAIN);
input.set(channelBytes, CONVERSATION_DOMAIN.length);
input.set(rootBytes, CONVERSATION_DOMAIN.length + channelBytes.length);
return formatUuid(sha256(input).slice(0, 16));
}

export function deriveAgentConversationIdOrNull(
channelId: string | null | undefined,
rootEventId: string | null | undefined,
): string | null {
if (!channelId || !rootEventId) return null;
try {
return deriveAgentConversationId(channelId, rootEventId);
} catch {
return null;
}
}
4 changes: 2 additions & 2 deletions desktop/src/features/agents/lib/personaCatalogRelay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
BOB,
);

assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer");
assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`);
assert.equal(personas[0].isActive, false);
});

Expand All @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
ALICE,
);

assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer");
assert.equal(personas[0].id, `catalog:${BOB}:reviewer`);
assert.equal(personas[0].isActive, false);
});

Expand Down
18 changes: 15 additions & 3 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout"
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import {
useChannelWorkingAgentPubkeys,
useConversationWorkingAgentPubkeys,
} from "@/features/agents/agentWorkingSignal";
import { deriveAgentConversationIdOrNull } from "@/features/agents/conversationId";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
import {
Expand Down Expand Up @@ -423,8 +427,16 @@ export const ChannelPane = React.memo(function ChannelPane({
) === index,
);
}, [botTypingEntries, openThreadHeadId]);
const threadComposerConversationId = React.useMemo(
() => deriveAgentConversationIdOrNull(activeChannel?.id, openThreadHeadId),
[activeChannel?.id, openThreadHeadId],
);
const threadComposerWorkingBotPubkeys = useConversationWorkingAgentPubkeys(
threadComposerConversationId,
threadComposerBotTypingPubkeys,
);
const hasThreadComposerBotActivity =
threadComposerBotTypingPubkeys.length > 0;
threadComposerWorkingBotPubkeys.length > 0;
const directMessageIntro = React.useMemo(
() =>
buildDirectMessageIntro({
Expand Down Expand Up @@ -894,7 +906,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={threadComposerBotTypingPubkeys}
workingBotPubkeys={threadComposerWorkingBotPubkeys}
variant="inline"
/>
) : null
Expand Down
31 changes: 29 additions & 2 deletions mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ typedef RelaySocketFactory =
});

class RelaySessionNotifier extends Notifier<SessionState> {
static const _shortBackgroundThreshold = Duration(seconds: 5);

RelaySessionNotifier({
http.Client? httpClient,
RelaySocketFactory socketFactory = RelaySocket.new,
Expand Down Expand Up @@ -103,6 +105,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
int _subIdCounter = 0;
bool _disposed = false;
bool _paused = false;
DateTime? _pausedAt;
bool _hasConnectedOnce = false;
int _connectionGeneration = 0;

Expand Down Expand Up @@ -307,6 +310,10 @@ class RelaySessionNotifier extends Notifier<SessionState> {

/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
// Invalidate callbacks from the socket being replaced before closing it.
// Some WebSocket implementations deliver onDone asynchronously, which
// must not schedule a second reconnect while this one is in progress.
_connectionGeneration++;
await _socket?.disconnect();
_reconnectDelayMs = _baseReconnectDelayMs;
final config = ref.read(relayConfigProvider);
Expand All @@ -315,6 +322,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {

/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_pausedAt = DateTime.now();
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow);
}
Expand All @@ -330,12 +338,30 @@ class RelaySessionNotifier extends Notifier<SessionState> {

/// Called by the app lifecycle provider when the app returns to foreground.
void onAppResumed() {
final pausedAt = _pausedAt;
_pausedAt = null;
_paused = false;
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = null;

// If still connected, nothing to do — the socket survived the background
// grace window.
// A suspended isolate may not run the grace timer. Preserve a very short
// app switch only when the connected socket saw a recent data frame;
// otherwise replace it so a half-open socket cannot remain "connected".
if (pausedAt != null) {
final now = DateTime.now();
final socket = _socket;
final hasRecentInbound =
socket?.state == SocketState.connected &&
socket?.lastInboundAt != null &&
now.difference(socket!.lastInboundAt!) <= _shortBackgroundThreshold;
if (now.difference(pausedAt) < _shortBackgroundThreshold &&
hasRecentInbound) {
return;
}
unawaited(reconnect());
return;
}

if (state.status == SessionStatus.connected) return;

// Cancel any in-flight reconnect backoff timer so we reconnect immediately
Expand Down Expand Up @@ -639,6 +665,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_reconnectTimer?.cancel();
_flushTimer?.cancel();
_backgroundGraceTimer?.cancel();
_pausedAt = null;
_cancelAllHistory(null);
_rejectAllPending(null);
_recentDeliveryKeys.clear();
Expand Down
Loading