@zhin.js/host-http 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,47 +1,205 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import type { ServerResponse } from 'node:http';
3
+ import type {
4
+ ConsoleEventEnvelope,
5
+ ConsoleEventHistoryPage,
6
+ ConsoleEventHistoryQuery,
7
+ ConsoleEventPayloadMap,
8
+ KnownConsoleEventType,
9
+ } from '@zhin.js/console-protocol';
2
10
 
3
11
  /**
4
12
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
5
13
  * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
6
14
  * write 失败或连接断开自动摘除订阅。
7
15
  */
16
+ export interface ConsoleEventSubscriptionOptions {
17
+ readonly runtimeId?: string;
18
+ readonly after?: number;
19
+ }
20
+
8
21
  export interface ConsoleEventHub {
9
- publish(type: string, data: unknown): void;
10
- /** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
11
- subscribe(response: ServerResponse): () => void;
22
+ publish<Type extends string>(
23
+ type: Type,
24
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
25
+ ): ConsoleEventEnvelope<
26
+ Type,
27
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
28
+ >;
29
+ history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
30
+ /** Atomically replay the resumable suffix, then join live fan-out. */
31
+ subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
32
+ readonly runtimeId: string;
12
33
  readonly subscriberCount: number;
13
34
  }
14
35
 
15
- export function createConsoleEventHub(): ConsoleEventHub {
36
+ export interface ConsoleEventHubOptions {
37
+ readonly runtimeId?: string;
38
+ readonly historyLimit?: number;
39
+ readonly historyByteLimit?: number;
40
+ readonly historyPageByteLimit?: number;
41
+ }
42
+
43
+ const DEFAULT_HISTORY_LIMIT = 256;
44
+ const DEFAULT_PAGE_LIMIT = 200;
45
+ const MAX_PAGE_LIMIT = 500;
46
+ const MAX_EVENT_BYTES = 64 * 1024;
47
+ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
48
+ const DEFAULT_HISTORY_PAGE_BYTE_LIMIT = 1024 * 1024;
49
+
50
+ interface JournalEntry {
51
+ readonly event: ConsoleEventEnvelope;
52
+ readonly frame: string;
53
+ readonly bytes: number;
54
+ }
55
+
56
+ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): ConsoleEventHub {
16
57
  let nextId = 0;
58
+ const runtimeId = options.runtimeId ?? randomUUID();
59
+ const historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
60
+ const historyByteLimit = positiveInteger(options.historyByteLimit, DEFAULT_HISTORY_BYTE_LIMIT);
61
+ const historyPageByteLimit = positiveInteger(
62
+ options.historyPageByteLimit,
63
+ DEFAULT_HISTORY_PAGE_BYTE_LIMIT,
64
+ );
65
+ const journal: JournalEntry[] = [];
66
+ let journalBytes = 0;
17
67
  const subscribers = new Set<ServerResponse>();
18
68
 
19
69
  const remove = (response: ServerResponse): void => {
20
70
  subscribers.delete(response);
21
71
  };
22
72
 
73
+ const write = (response: ServerResponse, entry: JournalEntry): void => {
74
+ let failed = false;
75
+ const accepted = response.write(entry.frame, (error) => {
76
+ if (error) {
77
+ failed = true;
78
+ remove(response);
79
+ }
80
+ });
81
+ if (failed) throw new Error('Console event subscriber rejected replay');
82
+ if (!accepted) {
83
+ remove(response);
84
+ response.destroy(new Error('Console event subscriber backpressure limit reached'));
85
+ throw new Error('Console event subscriber backpressure limit reached');
86
+ }
87
+ };
88
+
23
89
  return {
90
+ runtimeId,
24
91
  get subscriberCount() {
25
92
  return subscribers.size;
26
93
  },
27
- publish(type, data) {
28
- nextId += 1;
29
- const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
94
+ publish<Type extends string>(
95
+ type: Type,
96
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
97
+ ): ConsoleEventEnvelope<
98
+ Type,
99
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
100
+ > {
101
+ const snapshot = jsonSnapshot(data);
102
+ const event = Object.freeze({
103
+ runtimeId,
104
+ eventId: ++nextId,
105
+ type,
106
+ data: snapshot,
107
+ timestamp: Date.now(),
108
+ });
109
+ const frame = serializeConsoleEvent(event);
110
+ const entry = Object.freeze({ event, frame, bytes: Buffer.byteLength(frame, 'utf8') });
111
+ journal.push(entry);
112
+ journalBytes += entry.bytes;
113
+ while (journal.length > historyLimit || journalBytes > historyByteLimit) {
114
+ journalBytes -= journal.shift()?.bytes ?? 0;
115
+ }
30
116
  for (const response of [...subscribers]) {
31
117
  try {
32
- response.write(frame, (error) => {
33
- if (error) remove(response);
34
- });
118
+ write(response, entry);
35
119
  } catch {
36
120
  remove(response);
37
121
  }
38
122
  }
123
+ return event as ConsoleEventEnvelope<
124
+ Type,
125
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
126
+ >;
39
127
  },
40
- subscribe(response) {
41
- subscribers.add(response);
128
+ history(query = {}): ConsoleEventHistoryPage {
129
+ const requestedAfter = nonNegativeInteger(query.after, 0);
130
+ const sameRuntime = !query.runtimeId || query.runtimeId === runtimeId;
131
+ const cursorAhead = requestedAfter > nextId;
132
+ const after = sameRuntime && !cursorAhead ? requestedAfter : 0;
133
+ const limit = Math.min(MAX_PAGE_LIMIT, positiveInteger(query.limit, DEFAULT_PAGE_LIMIT));
134
+ const oldestAvailableEventId = journal[0]?.event.eventId ?? null;
135
+ const latestEventId = nextId;
136
+ const gap = !sameRuntime || cursorAhead
137
+ || (oldestAvailableEventId !== null
138
+ ? after < oldestAvailableEventId - 1
139
+ : after < latestEventId);
140
+ const available = journal.filter((entry) => entry.event.eventId > after);
141
+ const selected: ConsoleEventEnvelope[] = [];
142
+ let selectedBytes = 0;
143
+ for (const entry of available) {
144
+ if (selected.length >= limit) break;
145
+ if (selected.length > 0 && selectedBytes + entry.bytes > historyPageByteLimit) break;
146
+ selected.push(entry.event);
147
+ selectedBytes += entry.bytes;
148
+ }
149
+ const items = Object.freeze(selected);
150
+ const nextAfter = items.at(-1)?.eventId ?? after;
151
+ return Object.freeze({
152
+ runtimeId,
153
+ items,
154
+ oldestAvailableEventId,
155
+ latestEventId,
156
+ nextAfter,
157
+ hasMore: available.length > items.length,
158
+ gap,
159
+ });
160
+ },
161
+ subscribe(response, options = {}) {
162
+ // Replay and subscription are synchronous relative to publish(), closing
163
+ // the race between HTTP recovery and the live stream hand-off.
164
+ const requestedAfter = nonNegativeInteger(options.after, 0);
165
+ const after = options.runtimeId && options.runtimeId !== runtimeId
166
+ || requestedAfter > nextId
167
+ ? 0
168
+ : requestedAfter;
169
+ try {
170
+ for (const entry of journal) {
171
+ if (entry.event.eventId > after) write(response, entry);
172
+ }
173
+ subscribers.add(response);
174
+ } catch {
175
+ remove(response);
176
+ }
42
177
  response.once('close', () => remove(response));
43
178
  response.once('error', () => remove(response));
44
179
  return () => remove(response);
45
180
  },
46
181
  };
47
182
  }
183
+
184
+ export function serializeConsoleEvent(event: ConsoleEventEnvelope): string {
185
+ return `id: ${event.eventId}\nevent: ${event.type}\nruntime: ${event.runtimeId}\ntimestamp: ${event.timestamp}\ndata: ${JSON.stringify(event.data)}\n\n`;
186
+ }
187
+
188
+ function positiveInteger(value: unknown, fallback: number): number {
189
+ const parsed = Number(value);
190
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
191
+ }
192
+
193
+ function nonNegativeInteger(value: unknown, fallback: number): number {
194
+ const parsed = Number(value);
195
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
196
+ }
197
+
198
+ function jsonSnapshot(value: unknown): unknown {
199
+ const encoded = JSON.stringify(value ?? null);
200
+ if (encoded === undefined) throw new TypeError('Console event data must be JSON serializable');
201
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_EVENT_BYTES) {
202
+ throw new RangeError(`Console event data exceeds ${MAX_EVENT_BYTES} bytes`);
203
+ }
204
+ return JSON.parse(encoded) as unknown;
205
+ }
@@ -47,19 +47,23 @@ export interface ConsoleRestCtx {
47
47
  /** 内省数据门面 — 由 basic/cli 用 agent 包 collectIntrospection* 装配。 */
48
48
  export interface ConsoleAgentIntrospection {
49
49
  commands?(): readonly unknown[];
50
+ middlewares?(): readonly unknown[];
51
+ components?(): readonly unknown[];
50
52
  bindings?(): readonly unknown[];
51
53
  tools?(): readonly unknown[];
52
54
  mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
55
+ renderComponent?(input: Readonly<{
56
+ requester: string;
57
+ name: string;
58
+ props: unknown;
59
+ signal: AbortSignal;
60
+ }>): Promise<unknown>;
53
61
  }
54
62
 
55
63
  /** Session tree 门面 — 形状对齐 agent 包 SessionTreeRuntimeHandle。 */
56
64
  export interface ConsoleAgentSessionTree {
57
65
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
58
- agentSessionStore: {
59
- getBySessionId(sessionId: string): Promise<
60
- { active_leaf_message_id?: number | null } | null | undefined
61
- >;
62
- };
66
+ getActiveLeafMessageId(sessionId: string): Promise<number | null>;
63
67
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
64
68
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
65
69
  jumpToBranchIndex(
@@ -83,6 +87,12 @@ export interface ConsoleRestPagesOptions {
83
87
  readonly pluginRegistryUrl?: string;
84
88
  /** 默认 `https://registry.npmmirror.com`(legacy 同源)。 */
85
89
  readonly npmRegistryUrl?: string;
90
+ /** Component preview deadline; default 5 seconds. */
91
+ readonly componentPreviewTimeoutMs?: number;
92
+ /** Maximum serialized Component preview; default 256 KiB. */
93
+ readonly componentPreviewMaxBytes?: number;
94
+ /** Maximum in-flight Component previews per Console route set; default 2. */
95
+ readonly componentPreviewMaxConcurrent?: number;
86
96
  }
87
97
 
88
98
  type LogModelLike = {
@@ -109,6 +119,8 @@ type LogRow = {
109
119
 
110
120
  const INTROSPECTION_PAGE_SIZES = {
111
121
  commands: 25,
122
+ middlewares: 30,
123
+ components: 30,
112
124
  tools: 15,
113
125
  endpoints: 30,
114
126
  bindings: 30,
@@ -143,7 +155,7 @@ export function registerConsoleRestPages(
143
155
 
144
156
  registerLogsRoutes(route, base, ctx);
145
157
  registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl);
146
- registerIntrospectionRoutes(route, base, ctx);
158
+ registerIntrospectionRoutes(route, base, ctx, options);
147
159
  registerAgentSessionRoutes(route, base, ctx);
148
160
 
149
161
  return () => {
@@ -551,6 +563,7 @@ function registerIntrospectionRoutes(
551
563
  route: HttpHost['route'],
552
564
  base: string,
553
565
  ctx: ConsoleRestCtx,
566
+ options: ConsoleRestPagesOptions,
554
567
  ): void {
555
568
  const collectors: Array<{
556
569
  kind: IntrospectionKind;
@@ -559,13 +572,33 @@ function registerIntrospectionRoutes(
559
572
  }> = [
560
573
  {
561
574
  kind: 'commands',
562
- collect: () => ({ rows: agentIntrospection(ctx)?.commands?.() ?? [] }),
575
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.commands?.() ?? []) }),
563
576
  fields: [
564
577
  (c) => stringProp(c, 'pattern'),
565
578
  (c) => stringProp(c, 'desc'),
566
579
  (c) => stringProp(c, 'plugin'),
567
580
  ],
568
581
  },
582
+ {
583
+ kind: 'middlewares',
584
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.middlewares?.() ?? []) }),
585
+ fields: [
586
+ (m) => stringProp(m, 'name'),
587
+ (m) => stringProp(m, 'owner'),
588
+ (m) => stringProp(m, 'phase'),
589
+ (m) => stringProp(m, 'target'),
590
+ (m) => stringProp(m, 'source'),
591
+ ],
592
+ },
593
+ {
594
+ kind: 'components',
595
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.components?.() ?? []) }),
596
+ fields: [
597
+ (c) => stringProp(c, 'name'),
598
+ (c) => stringProp(c, 'owner'),
599
+ (c) => stringProp(c, 'source'),
600
+ ],
601
+ },
569
602
  {
570
603
  kind: 'endpoints',
571
604
  collect: () => ({
@@ -580,7 +613,7 @@ function registerIntrospectionRoutes(
580
613
  },
581
614
  {
582
615
  kind: 'bindings',
583
- collect: () => ({ rows: agentIntrospection(ctx)?.bindings?.() ?? [] }),
616
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.bindings?.() ?? []) }),
584
617
  fields: [
585
618
  (a) => stringProp(a, 'name'),
586
619
  (a) => stringProp(a, 'provider'),
@@ -589,7 +622,7 @@ function registerIntrospectionRoutes(
589
622
  },
590
623
  {
591
624
  kind: 'tools',
592
- collect: () => ({ rows: agentIntrospection(ctx)?.tools?.() ?? [] }),
625
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.tools?.() ?? []) }),
593
626
  fields: [
594
627
  (t) => stringProp(t, 'name'),
595
628
  (t) => stringProp(t, 'source'),
@@ -599,7 +632,7 @@ function registerIntrospectionRoutes(
599
632
  {
600
633
  kind: 'mcp',
601
634
  collect: () => {
602
- const result = agentIntrospection(ctx)?.mcp?.();
635
+ const result = withAgentIntrospection(ctx, (value) => value?.mcp?.());
603
636
  if (result == null) return { rows: [] as readonly unknown[] };
604
637
  // readonly 数组不能用 Array.isArray 收窄,按对象形状判断
605
638
  if (typeof result === 'object' && 'rows' in result) {
@@ -625,7 +658,7 @@ function registerIntrospectionRoutes(
625
658
  );
626
659
  const missing = kind === 'endpoints'
627
660
  ? !ctx.getEndpoints
628
- : !agentIntrospection(ctx);
661
+ : !hasAgentIntrospection(ctx);
629
662
  const degradedNote = note ?? (missing
630
663
  ? kind === 'endpoints'
631
664
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
@@ -644,18 +677,146 @@ function registerIntrospectionRoutes(
644
677
  }
645
678
  }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
646
679
  }
680
+
681
+ let activeComponentPreviews = 0;
682
+ route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
683
+ if (!requireWriteScope(response, ctx, authScope)) return;
684
+ const body = (await readJsonBody<Record<string, unknown>>(request)) ?? {};
685
+ const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
686
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
687
+ if (!requester || !name || requester.length > 256 || name.length > 160) {
688
+ writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
689
+ return;
690
+ }
691
+ const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
692
+ if (activeComponentPreviews >= maxConcurrent) {
693
+ writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
694
+ return;
695
+ }
696
+ const lease = ctx.acquireAgentRuntime?.();
697
+ const render = lease?.value.introspection?.renderComponent;
698
+ if (!lease || !render) {
699
+ lease?.release();
700
+ writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
701
+ return;
702
+ }
703
+ const controller = new AbortController();
704
+ const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
705
+ const timeout = setTimeout(() => controller.abort(
706
+ new ComponentPreviewTimeoutError(`Component preview 超过 ${timeoutMs}ms`),
707
+ ), timeoutMs);
708
+ activeComponentPreviews += 1;
709
+ let released = false;
710
+ const release = () => {
711
+ if (released) return;
712
+ released = true;
713
+ activeComponentPreviews -= 1;
714
+ lease.release();
715
+ };
716
+ const renderPromise = Promise.resolve().then(() =>
717
+ render({ requester, name, props: body.props ?? {}, signal: controller.signal }));
718
+ // A timed-out implementation may ignore AbortSignal. Keep its generation
719
+ // lease until the real operation settles; the bounded concurrency gate
720
+ // prevents non-cooperative previews from pinning unbounded generations.
721
+ void renderPromise.then(release, release);
722
+ try {
723
+ const output = await raceWithAbort(
724
+ renderPromise,
725
+ controller.signal,
726
+ );
727
+ assertPreviewOutput(output, options.componentPreviewMaxBytes ?? 256 * 1024);
728
+ writeJson(response, 200, { success: true, data: { requester, name, output } });
729
+ } catch (error) {
730
+ const status = error instanceof ComponentPreviewTimeoutError
731
+ ? 408
732
+ : error instanceof ComponentPreviewLimitError ? 413 : 422;
733
+ writeJson(response, status, {
734
+ success: false,
735
+ error: error instanceof Error ? error.message : String(error),
736
+ });
737
+ } finally {
738
+ clearTimeout(timeout);
739
+ if (!controller.signal.aborted) controller.abort();
740
+ }
741
+ }, { summary: 'Render a Component preview', tags: ['console', 'introspection', 'component'] });
742
+ }
743
+
744
+ class ComponentPreviewTimeoutError extends Error {
745
+ override readonly name = 'ComponentPreviewTimeoutError';
746
+ }
747
+
748
+ class ComponentPreviewLimitError extends Error {
749
+ override readonly name = 'ComponentPreviewLimitError';
750
+ }
751
+
752
+ function clampPositiveInt(value: number | undefined, fallback: number, ceiling: number): number {
753
+ if (!Number.isFinite(value) || !value || value < 1) return fallback;
754
+ return Math.min(Math.floor(value), ceiling);
755
+ }
756
+
757
+ async function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
758
+ if (signal.aborted) throw abortReason(signal);
759
+ return await new Promise<T>((resolve, reject) => {
760
+ const onAbort = () => reject(abortReason(signal));
761
+ signal.addEventListener('abort', onAbort, { once: true });
762
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
763
+ });
764
+ }
765
+
766
+ function abortReason(signal: AbortSignal): Error {
767
+ return signal.reason instanceof Error
768
+ ? signal.reason
769
+ : new DOMException('Component preview aborted', 'AbortError');
770
+ }
771
+
772
+ function assertPreviewOutput(output: unknown, maxBytes: number): void {
773
+ const byteLimit = clampPositiveInt(maxBytes, 256 * 1024, 1024 * 1024);
774
+ const seen = new WeakSet<object>();
775
+ let nodes = 0;
776
+ const visit = (value: unknown, depth: number): void => {
777
+ nodes += 1;
778
+ if (nodes > 10_000) throw new ComponentPreviewLimitError('Component preview 节点数超过 10000');
779
+ if (depth > 24) throw new ComponentPreviewLimitError('Component preview 嵌套深度超过 24');
780
+ if (!value || typeof value !== 'object') return;
781
+ if (seen.has(value)) throw new ComponentPreviewLimitError('Component preview 不能包含循环引用');
782
+ seen.add(value);
783
+ if (Array.isArray(value)) {
784
+ for (const item of value) visit(item, depth + 1);
785
+ } else {
786
+ for (const item of Object.values(value as Record<string, unknown>)) visit(item, depth + 1);
787
+ }
788
+ };
789
+ visit(output, 0);
790
+ let serialized: string;
791
+ try {
792
+ serialized = JSON.stringify(output);
793
+ } catch (error) {
794
+ throw new ComponentPreviewLimitError(
795
+ `Component preview 无法序列化: ${error instanceof Error ? error.message : String(error)}`,
796
+ );
797
+ }
798
+ if (Buffer.byteLength(serialized ?? '') > byteLimit) {
799
+ throw new ComponentPreviewLimitError(`Component preview 超过 ${byteLimit} bytes`);
800
+ }
647
801
  }
648
802
 
649
- function agentIntrospection(ctx: ConsoleRestCtx): ConsoleAgentIntrospection | undefined {
803
+ function withAgentIntrospection<T>(
804
+ ctx: ConsoleRestCtx,
805
+ operation: (value: ConsoleAgentIntrospection | undefined) => T,
806
+ ): T {
650
807
  const lease = ctx.acquireAgentRuntime?.();
651
- if (!lease) return undefined;
808
+ if (!lease) return operation(undefined);
652
809
  try {
653
- return lease.value.introspection;
810
+ return operation(lease.value.introspection);
654
811
  } finally {
655
812
  lease.release();
656
813
  }
657
814
  }
658
815
 
816
+ function hasAgentIntrospection(ctx: ConsoleRestCtx): boolean {
817
+ return withAgentIntrospection(ctx, (value) => value !== undefined);
818
+ }
819
+
659
820
  // ---------------------------------------------------------------------------
660
821
  // agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
661
822
  // ---------------------------------------------------------------------------
@@ -708,14 +869,14 @@ function registerAgentSessionRoutes(
708
869
  return;
709
870
  }
710
871
 
711
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
872
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
712
873
  const points = await runtime.listBranchPoints(sessionId);
713
874
  writeJson(response, 200, {
714
875
  success: true,
715
876
  data: {
716
877
  sessionKey: parsed.sessionKey,
717
878
  sessionId,
718
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
879
+ activeLeafMessageId,
719
880
  points,
720
881
  },
721
882
  });
@@ -782,28 +943,28 @@ function registerAgentSessionRoutes(
782
943
 
783
944
  if (branchIndex !== undefined) {
784
945
  const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
785
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
946
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
786
947
  writeJson(response, result.ok ? 200 : 400, {
787
948
  success: result.ok,
788
949
  message: result.message,
789
950
  data: {
790
951
  sessionKey: parsed.sessionKey,
791
952
  sessionId,
792
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
953
+ activeLeafMessageId,
793
954
  },
794
955
  });
795
956
  return;
796
957
  }
797
958
 
798
959
  const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
799
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
960
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
800
961
  writeJson(response, ok ? 200 : 400, {
801
962
  success: ok,
802
963
  message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
803
964
  data: {
804
965
  sessionKey: parsed.sessionKey,
805
966
  sessionId,
806
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
967
+ activeLeafMessageId,
807
968
  },
808
969
  });
809
970
  } finally {