Skip to content

Commit ac4d691

Browse files
grinevclaude
andcommitted
refactor(OTB-70): split the event bridge into transport, routing, session runtime state and Telegram delivery
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 823fcfb commit ac4d691

24 files changed

Lines changed: 2512 additions & 1852 deletions

‎src/app/managers/summary-aggregation-manager.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ type ToolFileCallback = (fileInfo: ToolFileInfo) => void;
101101

102102
type QuestionCallback = (questions: Question[], requestID: string, sessionId: string) => void;
103103

104-
type QuestionErrorCallback = () => void;
104+
type QuestionErrorCallback = (sessionId: string) => void;
105105

106106
type ThinkingCallback = (update: ThinkingUpdate) => void;
107107

@@ -115,9 +115,9 @@ export interface TokensInfo {
115115
cacheWrite: number;
116116
}
117117

118-
type TokensCallback = (tokens: TokensInfo, isCompleted: boolean) => void;
118+
type TokensCallback = (sessionId: string, tokens: TokensInfo, isCompleted: boolean) => void;
119119

120-
type CostCallback = (cost: number) => void;
120+
type CostCallback = (sessionId: string, cost: number) => void;
121121

122122
export type SubagentStatus = "pending" | "running" | "completed" | "error";
123123

@@ -169,7 +169,7 @@ type PermissionRepliedCallback = (sessionId: string, requestID: string) => void
169169

170170
type SessionDiffCallback = (sessionId: string, diffs: FileChange[]) => void;
171171

172-
type FileChangeCallback = (change: FileChange) => void;
172+
type FileChangeCallback = (sessionId: string, change: FileChange) => void;
173173

174174
type ClearedCallback = () => void;
175175

@@ -599,6 +599,23 @@ export class SummaryAggregator {
599599
return this.isTrackedChildSession(sessionId);
600600
}
601601

602+
/**
603+
* The root session a tracked session belongs to, found by walking its parents;
604+
* an untracked session is its own root.
605+
*/
606+
getRootSessionId(sessionId: string): string {
607+
const visited = new Set<string>();
608+
let rootSessionId = sessionId;
609+
let parentSessionId = this.trackedSessionParents.get(rootSessionId);
610+
while (parentSessionId && !visited.has(parentSessionId)) {
611+
visited.add(rootSessionId);
612+
rootSessionId = parentSessionId;
613+
parentSessionId = this.trackedSessionParents.get(rootSessionId);
614+
}
615+
616+
return rootSessionId;
617+
}
618+
602619
private getQueue(map: Map<string, string[]>, parentSessionId: string): string[] {
603620
const existing = map.get(parentSessionId);
604621
if (existing) {
@@ -1237,7 +1254,7 @@ export class SummaryAggregator {
12371254
`[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}, cacheRead=${tokens.cacheRead}, cacheWrite=${tokens.cacheWrite}, completed=${isCompleted}`,
12381255
);
12391256
// Call synchronously so keyboardManager is updated before onComplete sends the reply
1240-
this.onTokensCallback(tokens, isCompleted);
1257+
this.onTokensCallback(info.sessionID, tokens, isCompleted);
12411258
}
12421259

12431260
if (isCompleted) {
@@ -1261,7 +1278,7 @@ export class SummaryAggregator {
12611278
// Extract and report cost
12621279
if (this.onCostCallback && assistantInfo.cost !== undefined) {
12631280
logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`);
1264-
this.onCostCallback(assistantInfo.cost);
1281+
this.onCostCallback(info.sessionID, assistantInfo.cost);
12651282
}
12661283

12671284
if (this.onCompleteCallback && finalText.length > 0) {
@@ -1463,8 +1480,9 @@ export class SummaryAggregator {
14631480
`[Aggregator] Question tool failed with error, clearing active poll. callID=${part.callID}`,
14641481
);
14651482
if (this.onQuestionErrorCallback) {
1483+
const sessionId = part.sessionID;
14661484
setImmediate(() => {
1467-
this.onQuestionErrorCallback!();
1485+
this.onQuestionErrorCallback!(sessionId);
14681486
});
14691487
}
14701488
return;
@@ -1524,7 +1542,7 @@ export class SummaryAggregator {
15241542
}
15251543

15261544
if (preparedFileContext.fileChange && this.onFileChangeCallback) {
1527-
this.onFileChangeCallback(preparedFileContext.fileChange);
1545+
this.onFileChangeCallback(part.sessionID, preparedFileContext.fileChange);
15281546
}
15291547
}
15301548
}

‎src/app/services/event-router.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Event } from "@opencode-ai/sdk/v2";
2+
import { config } from "../../config.js";
3+
import type { AppContainer } from "../bootstrap/app-container.js";
4+
import type { EventEnvelope } from "../../opencode/events.js";
5+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
6+
import { markAttachedSessionBusy } from "./attach-service.js";
7+
import { reconcileBusyState } from "./busy-reconciliation-service.js";
8+
import { ingestSessionInfoForCache } from "./session-cache-service.js";
9+
10+
export type EventRouterDeps = Pick<
11+
AppContainer,
12+
| "assistantRunState"
13+
| "attachManager"
14+
| "backgroundSessionTracker"
15+
| "foregroundSessionState"
16+
| "scheduledTaskRuntime"
17+
| "summaryAggregator"
18+
>;
19+
20+
export interface EventRouterOptions {
21+
/** The directory the subscription was opened for. */
22+
directory: string;
23+
deps: EventRouterDeps;
24+
isForegroundSession: (sessionId: string) => boolean;
25+
}
26+
27+
/** The session an event belongs to, read from the event itself. */
28+
function getEventSessionId(event: Event): string | null {
29+
const properties = event.properties as {
30+
sessionID?: string;
31+
info?: { sessionID?: string };
32+
part?: { sessionID?: string };
33+
};
34+
35+
return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
36+
}
37+
38+
function shouldMarkAttachedBusyFromEvent(event: Event): boolean {
39+
switch (event.type) {
40+
case "session.status":
41+
return (event.properties as { status?: { type?: string } }).status?.type === "busy";
42+
case "message.updated": {
43+
const info = (event.properties as { info?: { role?: string; time?: { completed?: number } } })
44+
.info;
45+
return info?.role === "assistant" && !info.time?.completed;
46+
}
47+
case "message.part.updated":
48+
case "message.part.delta":
49+
case "question.asked":
50+
case "permission.asked":
51+
return true;
52+
default:
53+
return false;
54+
}
55+
}
56+
57+
/**
58+
* Routes events of one subscription. Session identity always comes from the
59+
* event; events without one only do subscription-wide work.
60+
*/
61+
export function createEventRouter({
62+
directory,
63+
deps,
64+
isForegroundSession,
65+
}: EventRouterOptions): (envelope: EventEnvelope) => void {
66+
return ({ event }) => {
67+
// The SDK event union does not list the heartbeat the server sends.
68+
if ((event as { type: string }).type === "server.heartbeat") {
69+
// A heartbeat is a liveness signal of the subscription, so the check runs
70+
// for the directory the subscription was opened with.
71+
void reconcileBusyState(directory, deps);
72+
}
73+
74+
const sessionId = getEventSessionId(event);
75+
const attached = deps.attachManager.getSnapshot();
76+
if (attached && sessionId === attached.sessionId && shouldMarkAttachedBusyFromEvent(event)) {
77+
void markAttachedSessionBusy(attached.sessionId, deps);
78+
}
79+
80+
if (event.type === "session.created" || event.type === "session.updated") {
81+
const info = event.properties.info;
82+
83+
if (info?.directory) {
84+
safeBackgroundTask({
85+
taskName: `session.cache.${event.type}`,
86+
task: () => ingestSessionInfoForCache(info),
87+
});
88+
}
89+
}
90+
91+
if (config.bot.trackBackgroundSessions) {
92+
const foregroundSessionId = sessionId && isForegroundSession(sessionId) ? sessionId : null;
93+
deps.backgroundSessionTracker.processEvent(event, foregroundSessionId);
94+
}
95+
96+
deps.summaryAggregator.processEvent(event);
97+
};
98+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { t } from "../../i18n/index.js";
2+
import type { BackgroundSessionNotification } from "../../app/managers/background-session-manager.js";
3+
import { buildBackgroundSessionOpenKeyboard } from "../menus/session-selection-menu.js";
4+
import type { SessionTargetPolicy, TelegramEventDelivery } from "./telegram-event-delivery.js";
5+
6+
function formatShortSessionId(sessionId: string): string {
7+
return sessionId.length <= 8 ? sessionId : sessionId.slice(0, 8);
8+
}
9+
10+
function getBackgroundSessionLabel(notification: BackgroundSessionNotification): string {
11+
const title = notification.sessionTitle?.trim();
12+
if (title) {
13+
return title;
14+
}
15+
16+
return t("background.session_fallback", {
17+
id: formatShortSessionId(notification.sessionId),
18+
});
19+
}
20+
21+
function formatBackgroundSessionNotification(notification: BackgroundSessionNotification): string {
22+
const session = getBackgroundSessionLabel(notification);
23+
24+
switch (notification.kind) {
25+
case "assistant_response":
26+
return t("background.assistant_response", { session });
27+
case "question_asked":
28+
return t("background.question_asked", { session });
29+
case "permission_asked":
30+
return t("background.permission_asked", { session });
31+
}
32+
}
33+
34+
/** Sends a background-session notice to the destination of its own session. */
35+
export function createBackgroundNoticeDelivery(
36+
policy: Pick<SessionTargetPolicy, "getDestination">,
37+
delivery: TelegramEventDelivery,
38+
): (notification: BackgroundSessionNotification) => Promise<void> {
39+
return async (notification) => {
40+
const destination = policy.getDestination(notification.sessionId);
41+
if (!destination) {
42+
return;
43+
}
44+
45+
await delivery.sendText(destination, formatBackgroundSessionNotification(notification), {
46+
reply_markup: buildBackgroundSessionOpenKeyboard(notification.sessionId, notification.kind),
47+
});
48+
};
49+
}

0 commit comments

Comments
 (0)