@myagentroam/node 0.9.7 → 0.9.9

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.
@@ -68,6 +68,7 @@ export declare class NodeConnector {
68
68
  private readonly workspaceChangeService;
69
69
  private readonly workspaceWorkbenchService;
70
70
  private database;
71
+ private readonly recentRunProjectionService;
71
72
  /** Runtime/session state is intentionally not persisted to Node SQLite. */
72
73
  private readonly runtime;
73
74
  private readonly metrics;
package/dist/connector.js CHANGED
@@ -30,6 +30,7 @@ import { WorkspaceChangeService } from './service/workspace-change-service.js';
30
30
  import { WorkspaceSessionService } from './service/workspace-session-service.js';
31
31
  import { WorkbenchEventService } from './service/workbench-event-service.js';
32
32
  import { RunDomainState } from './service/run-domain-state.js';
33
+ import { RecentRunProjectionService } from './service/recent-run-projection-service.js';
33
34
  import { SessionDomainState } from './service/session-domain-state.js';
34
35
  import { RunEventService } from './service/run-event-service.js';
35
36
  import { RunAttachmentService } from './service/run-attachment-service.js';
@@ -166,8 +167,9 @@ export class NodeConnector {
166
167
  workspaceChangeService = new WorkspaceChangeService(this.workspaceState);
167
168
  workspaceWorkbenchService;
168
169
  database;
170
+ recentRunProjectionService = new RecentRunProjectionService((message) => this.send('recent-runs', message));
169
171
  /** Runtime/session state is intentionally not persisted to Node SQLite. */
170
- runtime = new NodeRuntimeState(() => this.config?.nodeId ?? 'runtime-node', (workspaceId) => this.database?.getWorkspace(workspaceId));
172
+ runtime = new NodeRuntimeState(() => this.config?.nodeId ?? 'runtime-node', (workspaceId) => this.database?.getWorkspace(workspaceId), Date.now, (run, session) => this.recentRunProjectionService.record(run, session));
171
173
  metrics = new NodeMetrics();
172
174
  runEventService = new RunEventService({
173
175
  runtime: this.runtime,
@@ -684,7 +686,8 @@ export class NodeConnector {
684
686
  terminal: this.terminalChannel,
685
687
  metrics: this.metrics,
686
688
  heartbeatMs: HEARTBEAT_MS,
687
- refreshMs: CAPABILITY_REFRESH_MS
689
+ refreshMs: CAPABILITY_REFRESH_MS,
690
+ onConnected: () => this.recentRunProjectionService.publishSnapshot()
688
691
  });
689
692
  const requireDatabase = () => {
690
693
  if (this.database === undefined)
@@ -763,6 +766,7 @@ export class NodeConnector {
763
766
  }
764
767
  stop() {
765
768
  this.stopped = true;
769
+ this.recentRunProjectionService.dispose();
766
770
  this.upgradeCoordinator.stop();
767
771
  this.connectionLifecycle.stop();
768
772
  for (const approval of this.claudeClient.execution.approvals.values()) {
@@ -1,7 +1,7 @@
1
1
  import type { NodeAgentSession, NodeConversationItem, NodeConversationTurn, NodeMessageAttachmentInput } from '../../database.js';
2
2
  export type PublicConversationItem = {
3
3
  readonly itemId: string;
4
- readonly kind: 'reasoning_summary' | 'tool_call' | 'command_execution' | 'user_input_request' | 'plan' | 'context_compaction' | 'file_change' | 'error' | 'unknown';
4
+ readonly kind: 'assistant_message' | 'reasoning_summary' | 'tool_call' | 'command_execution' | 'user_input_request' | 'plan' | 'context_compaction' | 'file_change' | 'error' | 'unknown';
5
5
  readonly status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' | 'DECLINED';
6
6
  readonly payload: Record<string, unknown>;
7
7
  readonly merge?: boolean;
@@ -1,5 +1,6 @@
1
1
  const MAX_COMPOSER_ATTACHMENT_BYTES = 5 * 1024 * 1024;
2
2
  const MAX_COMPOSER_ATTACHMENTS_BYTES = 10 * 1024 * 1024;
3
+ const MAX_ASSISTANT_MESSAGE_CHARACTERS = 64 * 1024;
3
4
  export * from '../../util/runner-native-session-parsers.js';
4
5
  export function codexOfficialConversationPage(session, result, input, managedTurnForNative) {
5
6
  if (!isPlainRecord(result) || !isPlainRecord(result.thread))
@@ -43,7 +44,7 @@ export function codexOfficialTurn(session, value, sequence, managedTurn) {
43
44
  const officialNativeItemIds = codexNativeItemIds(value.items);
44
45
  const officialAssistantCount = items.filter((item) => item.kind === 'assistant_message').length;
45
46
  const officialAssistantMessage = officialAssistantCount > 0;
46
- const managedAssistants = managedTurn.items.filter((item) => item.kind === 'assistant_message');
47
+ const managedAssistants = managedTurn.items.filter((item) => item.kind === 'assistant_message' && conversationItemNativeId(item.id) === undefined);
47
48
  const managedAssistantCandidates = officialAssistantCount === 0 ? [] : managedAssistants.slice(-officialAssistantCount);
48
49
  const assistantLength = (values) => values.reduce((length, item) => {
49
50
  if (item.kind !== 'assistant_message' || !isPlainRecord(item.payload))
@@ -108,6 +109,15 @@ export function codexOfficialTurn(session, value, sequence, managedTurn) {
108
109
  replacement = officialAssistants.find((item) => !consumedOfficialIds.has(item.id));
109
110
  }
110
111
  if (replacement !== undefined) {
112
+ if (managedItem.kind === 'assistant_message' &&
113
+ replacement.kind === 'assistant_message' &&
114
+ (assistantLength([managedItem]) > assistantLength([replacement]) ||
115
+ ((managedTurn.completedAt ?? 0) > (completedAt ?? 0) &&
116
+ assistantLength([managedItem]) > 0))) {
117
+ reconciledItems.push(managedItem);
118
+ consumedOfficialIds.add(replacement.id);
119
+ continue;
120
+ }
111
121
  if (!consumedOfficialIds.has(replacement.id)) {
112
122
  reconciledItems.push(replacement);
113
123
  consumedOfficialIds.add(replacement.id);
@@ -504,6 +514,20 @@ export function codexLiveConversationItem(value, completed, imageAttachments = [
504
514
  status: completed ? 'COMPLETED' : 'IN_PROGRESS',
505
515
  payload: { explanation: compactRunnerText(planText, 20_000), steps: [] }
506
516
  };
517
+ if (value.type === 'agentMessage') {
518
+ if (!completed || typeof value.text !== 'string' || value.text.length === 0)
519
+ return undefined;
520
+ return {
521
+ itemId,
522
+ kind: 'assistant_message',
523
+ status: 'COMPLETED',
524
+ payload: {
525
+ text: compactRunnerText(value.text, MAX_ASSISTANT_MESSAGE_CHARACTERS),
526
+ format: 'markdown',
527
+ model: null
528
+ }
529
+ };
530
+ }
507
531
  if (value.type === 'commandExecution') {
508
532
  const output = typeof value.aggregatedOutput === 'string' ? value.aggregatedOutput : '';
509
533
  return {
@@ -53,7 +53,7 @@ export interface CodexManagedRunHost<TAttachment> {
53
53
  }): void;
54
54
  emitItem(runId: string, item: {
55
55
  readonly itemId: string;
56
- readonly kind: 'reasoning_summary' | 'tool_call' | 'command_execution' | 'user_input_request' | 'plan' | 'context_compaction' | 'file_change' | 'error' | 'unknown';
56
+ readonly kind: 'reasoning_summary' | 'tool_call' | 'command_execution' | 'user_input_request' | 'assistant_message' | 'plan' | 'context_compaction' | 'file_change' | 'error' | 'unknown';
57
57
  readonly status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' | 'DECLINED';
58
58
  readonly payload: Record<string, unknown>;
59
59
  readonly merge?: boolean;
@@ -155,7 +155,13 @@ export class CodexManagedRunController {
155
155
  return;
156
156
  }
157
157
  if (notification.method === 'item/agentMessage/delta' && typeof params.delta === 'string') {
158
- this.host.emit(runId, 'text.delta', { text: params.delta });
158
+ const itemId = typeof params.itemId === 'string'
159
+ ? boundedConversationItemId('codex', params.itemId)
160
+ : undefined;
161
+ this.host.emit(runId, 'text.delta', {
162
+ text: params.delta,
163
+ ...(itemId === undefined ? {} : { itemId })
164
+ });
159
165
  return;
160
166
  }
161
167
  if (notification.method === 'item/reasoning/summaryTextDelta' &&
@@ -1,4 +1,5 @@
1
1
  import { boundedConversationItemId, compactRunnerText, compactRunnerValue } from '../codex/conversation-parser.js';
2
+ import { runtimeAssistantProjectionMoreComplete } from '../../util/runner-native-session-parsers.js';
2
3
  export function openCodeConversationPage(session, messages, input, managedTurn) {
3
4
  const records = messages.flatMap((value) => {
4
5
  const entry = record(value);
@@ -63,6 +64,9 @@ function openCodeTurn(session, user, userParts, assistants, sequence, managed) {
63
64
  const completedAt = Math.max(startedAt ?? 0, ...assistants.map(({ info }) => number(record(info.time)?.completed) ?? 0)) || startedAt;
64
65
  const id = managed?.id ?? `${session.id}:official:${nativeTurnId}`;
65
66
  const runId = managed?.runId ?? null;
67
+ const projectedItems = runtimeAssistantProjectionMoreComplete(managed, items)
68
+ ? managed.items
69
+ : items;
66
70
  return {
67
71
  id,
68
72
  sessionId: session.id,
@@ -72,7 +76,7 @@ function openCodeTurn(session, user, userParts, assistants, sequence, managed) {
72
76
  model: assistants.find(({ info }) => typeof info.modelID === 'string')?.info.modelID ?? null,
73
77
  effort: managed?.effort ?? null,
74
78
  access: managed?.access ?? null,
75
- items: items.map((value) => ({ ...value, turnId: id, runId })),
79
+ items: projectedItems.map((value) => ({ ...value, turnId: id, runId })),
76
80
  startedAt,
77
81
  completedAt
78
82
  };
@@ -10,6 +10,8 @@ export declare class NodeRuntimeState {
10
10
  private readonly nodeId;
11
11
  private readonly getWorkspace;
12
12
  private readonly now;
13
+ private readonly onRunChanged?;
14
+ private static readonly MAX_RUN_EVENTS_PER_RUN;
13
15
  private static readonly MAX_COMPLETED_TURNS_PER_SESSION;
14
16
  private readonly sessions;
15
17
  private readonly runs;
@@ -26,7 +28,7 @@ export declare class NodeRuntimeState {
26
28
  /** Bounded tombstones reject stale internal start envelopes after terminal cleanup. */
27
29
  private readonly terminalRunIds;
28
30
  private nextEventId;
29
- constructor(nodeId: () => string, getWorkspace: (id: string) => NodeWorkspace | undefined, now?: () => number);
31
+ constructor(nodeId: () => string, getWorkspace: (id: string) => NodeWorkspace | undefined, now?: () => number, onRunChanged?: ((run: NodeAgentRun, session: NodeAgentSession) => void) | undefined);
30
32
  activeWorkspaceLeaseCount(): number;
31
33
  activeSessionRun(sessionId: string): NodeAgentRun | undefined;
32
34
  hasActiveSessionLease(sessionId: string): boolean;
@@ -9,6 +9,8 @@ export class NodeRuntimeState {
9
9
  nodeId;
10
10
  getWorkspace;
11
11
  now;
12
+ onRunChanged;
13
+ static MAX_RUN_EVENTS_PER_RUN = 100;
12
14
  static MAX_COMPLETED_TURNS_PER_SESSION = 10;
13
15
  sessions = new Map();
14
16
  runs = new Map();
@@ -25,10 +27,11 @@ export class NodeRuntimeState {
25
27
  /** Bounded tombstones reject stale internal start envelopes after terminal cleanup. */
26
28
  terminalRunIds = new Set();
27
29
  nextEventId = 1;
28
- constructor(nodeId, getWorkspace, now = Date.now) {
30
+ constructor(nodeId, getWorkspace, now = Date.now, onRunChanged) {
29
31
  this.nodeId = nodeId;
30
32
  this.getWorkspace = getWorkspace;
31
33
  this.now = now;
34
+ this.onRunChanged = onRunChanged;
32
35
  }
33
36
  activeWorkspaceLeaseCount() {
34
37
  return this.leases.size;
@@ -118,6 +121,7 @@ export class NodeRuntimeState {
118
121
  };
119
122
  this.runs.set(run.id, run);
120
123
  this.leases.set(run.sessionId, run.id);
124
+ this.onRunChanged?.(run, session);
121
125
  return run;
122
126
  }
123
127
  getAgentSession(id) {
@@ -322,6 +326,7 @@ export class NodeRuntimeState {
322
326
  this.bumpQueueVersion(session.id);
323
327
  this.turns.set(session.id, [...(this.turns.get(session.id) ?? []), fixedTurn]);
324
328
  this.replaceSession(session.id, { lastActivityAt: now });
329
+ this.onRunChanged?.(run, this.requireSession(session.id));
325
330
  return { run, created: true };
326
331
  }
327
332
  sessionQueue(sessionId) {
@@ -434,6 +439,7 @@ export class NodeRuntimeState {
434
439
  throw new Error('RUN_STATE_INVALID');
435
440
  const value = { ...current, status: next, version: current.version + 1, updatedAt: this.now() };
436
441
  this.runs.set(id, value);
442
+ this.onRunChanged?.(value, this.requireSession(value.sessionId));
437
443
  if (terminal(next) && this.leases.get(value.sessionId) === value.id)
438
444
  this.leases.delete(value.sessionId);
439
445
  this.updateTurnForRun(id, (turn) => ({
@@ -464,7 +470,7 @@ export class NodeRuntimeState {
464
470
  ...(input.status === undefined ? {} : { status: input.status }),
465
471
  createdAt: this.now()
466
472
  };
467
- this.events.set(input.runId, [...prior, value]);
473
+ this.events.set(input.runId, [...prior, value].slice(-NodeRuntimeState.MAX_RUN_EVENTS_PER_RUN));
468
474
  if (input.status !== undefined)
469
475
  this.transitionRun(input.runId, input.status);
470
476
  this.projectEvent(value);
@@ -630,7 +636,13 @@ export class NodeRuntimeState {
630
636
  }
631
637
  this.runs.delete(runId);
632
638
  this.events.delete(runId);
633
- const turns = this.turns.get(run.sessionId) ?? [];
639
+ const turns = (this.turns.get(run.sessionId) ?? []).filter((turn) => {
640
+ const hasVisibleError = turn.items.some((item) => item.kind === 'error');
641
+ if (turn.runId === runId)
642
+ return run.status === 'SUCCEEDED' || run.status === 'INTERRUPTED' || hasVisibleError;
643
+ const isTransientErrorTurn = hasVisibleError && turn.status !== 'SUCCEEDED' && turn.status !== 'INTERRUPTED';
644
+ return run.status !== 'SUCCEEDED' || !isTransientErrorTurn;
645
+ });
634
646
  const completed = turns.filter((turn) => terminalTurn(turn.status));
635
647
  const retainedCompleted = new Set(completed.slice(-NodeRuntimeState.MAX_COMPLETED_TURNS_PER_SESSION).map((turn) => turn.id));
636
648
  this.turns.set(run.sessionId, turns.filter((turn) => !terminalTurn(turn.status) || retainedCompleted.has(turn.id)));
@@ -708,6 +720,9 @@ export class NodeRuntimeState {
708
720
  const value = this.requireSession(id);
709
721
  const next = { ...value, ...patch };
710
722
  this.sessions.set(id, next);
723
+ if (value.customTitle !== next.customTitle || value.runnerTitle !== next.runnerTitle)
724
+ for (const run of this.listRunsForSession(id))
725
+ this.onRunChanged?.(run, next);
711
726
  return next;
712
727
  }
713
728
  bumpQueueVersion(sessionId) {
@@ -742,13 +757,19 @@ export class NodeRuntimeState {
742
757
  if (event.eventType === 'text.delta' &&
743
758
  isRecord(event.payload) &&
744
759
  typeof event.payload.text === 'string') {
745
- const lastItem = turn.items.at(-1);
746
- const existing = lastItem?.kind === 'assistant_message' && activeConversationItem(lastItem.status)
747
- ? lastItem
760
+ const nativeItemId = typeof event.payload.itemId === 'string' && event.payload.itemId.length <= 88
761
+ ? event.payload.itemId
748
762
  : undefined;
763
+ const stableItemId = nativeItemId === undefined ? undefined : `${event.runId}:${nativeItemId}`;
764
+ const lastItem = turn.items.at(-1);
765
+ const existing = stableItemId === undefined
766
+ ? lastItem?.kind === 'assistant_message' && activeConversationItem(lastItem.status)
767
+ ? lastItem
768
+ : undefined
769
+ : turn.items.find((item) => item.id === stableItemId && item.kind === 'assistant_message');
749
770
  const text = `${existing === undefined ? '' : readableText(existing.payload)}${event.payload.text}`;
750
771
  const item = {
751
- id: existing?.id ?? `${event.runId}:assistant:${event.sequence}`,
772
+ id: existing?.id ?? stableItemId ?? `${event.runId}:assistant:${event.sequence}`,
752
773
  sessionId: run.sessionId,
753
774
  turnId: turn.id,
754
775
  runId: event.runId,
@@ -829,7 +850,7 @@ function terminal(status) {
829
850
  return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
830
851
  }
831
852
  function terminalTurn(status) {
832
- return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED'].includes(status);
853
+ return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
833
854
  }
834
855
  function transitionAllowed(from, to) {
835
856
  return !terminal(from) || from === to;
@@ -869,7 +890,8 @@ function conversationItemFromEvent(event) {
869
890
  };
870
891
  }
871
892
  function projectableConversationKind(value) {
872
- return (value === 'reasoning_summary' ||
893
+ return (value === 'assistant_message' ||
894
+ value === 'reasoning_summary' ||
873
895
  value === 'tool_call' ||
874
896
  value === 'command_execution' ||
875
897
  value === 'user_input_request' ||
@@ -17,6 +17,7 @@ interface NodeConnectionLifecycleServiceOptions {
17
17
  readonly metrics: NodeMetrics;
18
18
  readonly heartbeatMs: number;
19
19
  readonly refreshMs: number;
20
+ readonly onConnected?: () => void;
20
21
  }
21
22
  /** Owns registration completion, heartbeat and background capability refresh timers. */
22
23
  export declare class NodeConnectionLifecycleService {
@@ -47,6 +47,7 @@ export class NodeConnectionLifecycleService {
47
47
  this.options.control.send('capabilities', this.options.capabilities());
48
48
  this.options.terminal.connect();
49
49
  this.options.control.send('heartbeat', {});
50
+ this.options.onConnected?.();
50
51
  this.options.metrics.heartbeatSent();
51
52
  this.heartbeat = setInterval(() => {
52
53
  this.options.metrics.heartbeatSent();
@@ -0,0 +1,14 @@
1
+ import type { NodeAgentRun, NodeAgentSession } from '../database.js';
2
+ import type { RecentRunProjection, RecentRunProjectionMessage } from '@myagentroam/protocol';
3
+ export declare class RecentRunProjectionService {
4
+ private readonly emit;
5
+ private readonly now;
6
+ private readonly runs;
7
+ private readonly cleanupTimer;
8
+ constructor(emit: (message: RecentRunProjectionMessage) => void, now?: () => number);
9
+ record(run: NodeAgentRun, session: NodeAgentSession): void;
10
+ snapshot(): readonly RecentRunProjection[];
11
+ publishSnapshot(): void;
12
+ dispose(): void;
13
+ private cleanup;
14
+ }
@@ -0,0 +1,78 @@
1
+ const TERMINAL_TTL_MS = 12 * 60 * 60_000;
2
+ const CLEANUP_INTERVAL_MS = 5 * 60_000;
3
+ const TERMINAL_LIMIT = 100;
4
+ export class RecentRunProjectionService {
5
+ emit;
6
+ now;
7
+ runs = new Map();
8
+ cleanupTimer;
9
+ constructor(emit, now = Date.now) {
10
+ this.emit = emit;
11
+ this.now = now;
12
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
13
+ this.cleanupTimer.unref();
14
+ }
15
+ record(run, session) {
16
+ if (!Number.isSafeInteger(run.initiatedByUserId) || run.initiatedByUserId === undefined)
17
+ return;
18
+ const existing = this.runs.get(run.id);
19
+ if (existing !== undefined && existing.version > run.version)
20
+ return;
21
+ const startedAt = existing?.startedAt ?? (run.status === 'QUEUED' ? null : run.updatedAt);
22
+ const completedAt = terminal(run.status) ? (existing?.completedAt ?? run.updatedAt) : null;
23
+ const projection = {
24
+ runId: run.id,
25
+ nodeId: run.nodeId,
26
+ workspaceId: run.workspaceId,
27
+ sessionId: run.sessionId,
28
+ sessionTitle: session.customTitle ?? session.runnerTitle ?? session.id,
29
+ runner: run.runner,
30
+ status: run.status,
31
+ createdAt: run.createdAt,
32
+ startedAt,
33
+ completedAt,
34
+ sortAt: startedAt ?? run.createdAt,
35
+ updatedAt: run.updatedAt,
36
+ version: run.version,
37
+ userId: run.initiatedByUserId
38
+ };
39
+ this.runs.set(run.id, projection);
40
+ this.cleanup();
41
+ this.emit({ kind: 'UPDATE', run: projection });
42
+ }
43
+ snapshot() {
44
+ this.cleanup();
45
+ return [...this.runs.values()].sort(compareRecentRuns);
46
+ }
47
+ publishSnapshot() {
48
+ this.emit({ kind: 'SNAPSHOT', runs: [...this.snapshot()] });
49
+ }
50
+ dispose() {
51
+ clearInterval(this.cleanupTimer);
52
+ }
53
+ cleanup() {
54
+ const cutoff = this.now() - TERMINAL_TTL_MS;
55
+ for (const [runId, run] of this.runs)
56
+ if (run.completedAt !== null && run.completedAt <= cutoff)
57
+ this.runs.delete(runId);
58
+ const byUser = new Map();
59
+ for (const run of this.runs.values()) {
60
+ if (run.completedAt === null)
61
+ continue;
62
+ const values = byUser.get(run.userId) ?? [];
63
+ values.push(run);
64
+ byUser.set(run.userId, values);
65
+ }
66
+ for (const values of byUser.values())
67
+ for (const run of values.sort(compareRecentRuns).slice(TERMINAL_LIMIT))
68
+ this.runs.delete(run.runId);
69
+ }
70
+ }
71
+ function compareRecentRuns(left, right) {
72
+ return (right.sortAt - left.sortAt ||
73
+ right.createdAt - left.createdAt ||
74
+ right.runId.localeCompare(left.runId));
75
+ }
76
+ function terminal(status) {
77
+ return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED'].includes(status);
78
+ }
@@ -14,7 +14,7 @@ export class RunEventService {
14
14
  status === undefined &&
15
15
  isPlainRecord(payload) &&
16
16
  typeof payload.text === 'string') {
17
- this.enqueueText(runId, payload.text);
17
+ this.enqueueText(runId, payload.text, typeof payload.itemId === 'string' ? payload.itemId : undefined);
18
18
  return;
19
19
  }
20
20
  this.flushText(runId);
@@ -28,18 +28,20 @@ export class RunEventService {
28
28
  flushPendingText(runId) {
29
29
  this.flushText(runId);
30
30
  }
31
- enqueueText(runId, text) {
31
+ enqueueText(runId, text, itemId) {
32
32
  if (text.length === 0)
33
33
  return;
34
34
  const pending = this.pendingText.get(runId);
35
- if (pending !== undefined) {
35
+ if (pending !== undefined && pending.itemId === itemId) {
36
36
  pending.text += text;
37
37
  if (pending.text.length >= MAX_BATCH_CHARS)
38
38
  this.flushText(runId);
39
39
  return;
40
40
  }
41
+ if (pending !== undefined)
42
+ this.flushText(runId);
41
43
  const timer = setTimeout(() => this.flushText(runId), COALESCE_MS);
42
- this.pendingText.set(runId, { text, timer });
44
+ this.pendingText.set(runId, { text, ...(itemId === undefined ? {} : { itemId }), timer });
43
45
  }
44
46
  flushText(runId) {
45
47
  const pending = this.pendingText.get(runId);
@@ -47,7 +49,10 @@ export class RunEventService {
47
49
  return;
48
50
  clearTimeout(pending.timer);
49
51
  this.pendingText.delete(runId);
50
- this.emitNow(runId, 'text.delta', { text: pending.text });
52
+ this.emitNow(runId, 'text.delta', {
53
+ text: pending.text,
54
+ ...(pending.itemId === undefined ? {} : { itemId: pending.itemId })
55
+ });
51
56
  }
52
57
  emitNow(runId, eventType, payload, status) {
53
58
  if (this.options.runtime.getRun(runId) === undefined)
@@ -1,5 +1,5 @@
1
1
  import type { SessionActivityState } from '@myagentroam/protocol';
2
- import type { NodeAgentSession, NodeConversationTurn } from '../database.js';
2
+ import type { NodeAgentSession, NodeConversationItem, NodeConversationTurn } from '../database.js';
3
3
  import type { NativeSessionHistory, NativeTranscriptImageAttachment } from '../native-session-history.js';
4
4
  export interface SessionContextUsage {
5
5
  readonly model: string | null;
@@ -46,6 +46,7 @@ export declare function nativeConversationPage(session: NodeAgentSession, histor
46
46
  readonly turns: readonly NodeConversationTurn[];
47
47
  readonly nextCursor: string | null;
48
48
  };
49
+ export declare function runtimeAssistantProjectionMoreComplete(runtimeTurn: NodeConversationTurn | undefined, projectedItems: readonly NodeConversationItem[]): boolean;
49
50
  /**
50
51
  * Claude JSONL does not retain browser request IDs. While the Node process is alive, match current
51
52
  * runtime user messages by content and creation time so native history replaces the optimistic Turn.
@@ -156,19 +156,6 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
156
156
  const effectiveCursor = history.cursorReset === true ? undefined : input.cursor;
157
157
  const nativeTurns = nativeTranscriptTurns(history.items);
158
158
  const nativeTurnIds = nativeTranscriptTurnIds(session.id, nativeTurns);
159
- const representedClientMessageIds = new Set(nativeTurns.flatMap((entries) => entries.flatMap(({ entry, index }) => {
160
- if (entry.kind !== 'message' || entry.role !== 'USER')
161
- return [];
162
- const clientMessageId = nativeUserClientMessageId(entry, entry.createdAt ?? index, runtimeTurns);
163
- return clientMessageId === null ? [] : [clientMessageId];
164
- })));
165
- const missingRuntimeErrorTurns = effectiveCursor === undefined
166
- ? runtimeTurns.filter((turn) => {
167
- const clientMessageId = runtimeTurnClientMessageId(turn);
168
- return (turn.items.some((item) => item.kind === 'error') &&
169
- (clientMessageId === undefined || !representedClientMessageIds.has(clientMessageId)));
170
- })
171
- : [];
172
159
  const end = nativePageEnd(effectiveCursor, session.id, nativeTurns.length);
173
160
  const start = Math.max(0, end - limit);
174
161
  const nativePageTurns = nativeTurns
@@ -190,10 +177,16 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
190
177
  const runtimeErrors = (runtimeTurn?.items ?? [])
191
178
  .filter((item) => item.kind === 'error')
192
179
  .map((item) => ({ ...item, turnId, runId: runtimeTurn?.runId ?? null }));
193
- const items = [
194
- ...nativeItems,
195
- ...runtimeErrors.filter((error) => !nativeItems.some((item) => item.id === error.id))
196
- ];
180
+ const items = runtimeAssistantProjectionMoreComplete(runtimeTurn, nativeItems)
181
+ ? runtimeTurn.items.map((item) => ({
182
+ ...item,
183
+ turnId,
184
+ runId: runtimeTurn.runId
185
+ }))
186
+ : [
187
+ ...nativeItems,
188
+ ...runtimeErrors.filter((error) => !nativeItems.some((item) => item.id === error.id))
189
+ ];
197
190
  const startedAt = items[0]?.startedAt ?? null;
198
191
  const completedAt = items.at(-1)?.completedAt ?? startedAt;
199
192
  const after = lastNativeItemId(entries);
@@ -219,11 +212,8 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
219
212
  completedAt
220
213
  };
221
214
  });
222
- const combined = [...nativePageTurns, ...missingRuntimeErrorTurns];
223
- const dropped = combined.slice(0, Math.max(0, combined.length - limit));
224
- const turns = combined.slice(-limit);
225
- const nativePageIds = new Set(nativePageTurns.map((turn) => turn.id));
226
- const nextEnd = start + dropped.filter((turn) => nativePageIds.has(turn.id)).length;
215
+ const turns = nativePageTurns;
216
+ const nextEnd = start;
227
217
  const cursorBase = {
228
218
  sessionId: session.id,
229
219
  ...(history.snapshotEnd === undefined ? {} : { snapshotEnd: history.snapshotEnd }),
@@ -253,6 +243,22 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
253
243
  : precedingWindow
254
244
  };
255
245
  }
246
+ export function runtimeAssistantProjectionMoreComplete(runtimeTurn, projectedItems) {
247
+ if (runtimeTurn === undefined)
248
+ return false;
249
+ const assistantStats = (items) => items.reduce((stats, item) => {
250
+ if (item.kind !== 'assistant_message' || !isPlainRecord(item.payload))
251
+ return stats;
252
+ return {
253
+ count: stats.count + 1,
254
+ characters: stats.characters +
255
+ (typeof item.payload.text === 'string' ? item.payload.text.length : 0)
256
+ };
257
+ }, { count: 0, characters: 0 });
258
+ const runtime = assistantStats(runtimeTurn.items);
259
+ const projected = assistantStats(projectedItems);
260
+ return runtime.count > projected.count || runtime.characters > projected.characters;
261
+ }
256
262
  function runtimeTurnClientMessageId(turn) {
257
263
  const user = turn.items.find((item) => item.kind === 'user_message');
258
264
  return isPlainRecord(user?.payload) && typeof user.payload.clientMessageId === 'string'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "type": "module",
6
6
  "files": [
@@ -24,7 +24,7 @@
24
24
  "node-pty": "1.1.0",
25
25
  "ws": "^8.21.3",
26
26
  "zod": "4.4.3",
27
- "@myagentroam/protocol": "^0.9.7"
27
+ "@myagentroam/protocol": "^0.9.9"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/ws": "^8.18.1"