@springbrand/agent-runtime 0.3.0-alpha.18 → 0.3.0-alpha.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.3.0-alpha.18",
3
+ "version": "0.3.0-alpha.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -1072,7 +1072,7 @@ export abstract class AgentRuntimeKernel<
1072
1072
  /** Start the native resume handshake from Pi's current lane snapshot. */
1073
1073
  async onConnect(connection: Connection): Promise<void> {
1074
1074
  this.conversation.disconnect(connection);
1075
- await this.conversation.resume(connection);
1075
+ await this.conversation.connect(connection);
1076
1076
  }
1077
1077
 
1078
1078
  async onClose(connection: Connection, code: number, reason: string, wasClean: boolean): Promise<void> {
@@ -54,21 +54,28 @@ export class HarnessConversationBridge {
54
54
  await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: JSON.stringify(chunk), done: false, ...(replay ? { replay: true, continuation: false } : {}) });
55
55
  }
56
56
  }
57
- async resume(connection: Connection, probeId?: string): Promise<void> {
57
+ /**
58
+ * A socket just opened: offer the live stream if one exists, otherwise only
59
+ * sync messages. Like Cloudflare's AIChatAgent, an idle connect sends no
60
+ * STREAM_RESUME_NONE. The client answers its own resume probe from the
61
+ * reply that carries its probeId; an unsolicited, probe-less "none" arriving
62
+ * first consumes that pending probe without clearing the client's
63
+ * server-streaming state, which leaves a reconnected Chat stuck streaming
64
+ * after a Turn that ended while it was offline.
65
+ */
66
+ connect(connection: Connection): Promise<void> {
67
+ return this.resume(connection, undefined, false);
68
+ }
69
+ /** Answer a client's stream-resume request; `announceIdle` is false only for {@link connect}. */
70
+ async resume(connection: Connection, probeId?: string, announceIdle = true): Promise<void> {
58
71
  this.disconnect(connection);
59
72
  const source = this.active;
60
- if (!source) {
61
- await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
62
- await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
63
- return;
64
- }
73
+ if (!source) return this.idle(connection, probeId, announceIdle);
65
74
  const watch = await source.runtime.watch(BACKGROUND_CONTEXT);
66
- if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId); }
75
+ if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId, announceIdle); }
67
76
  if (!watch.snapshot.run) {
68
77
  await watch.stop();
69
- await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
70
- await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
71
- return;
78
+ return this.idle(connection, probeId, announceIdle);
72
79
  }
73
80
  const messages = await this.host.history.browserSnapshot(watch.snapshot, source.submissionId);
74
81
  const replay = this.replayMessage(messages, source.submissionId);
@@ -77,6 +84,15 @@ export class HarnessConversationBridge {
77
84
  await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: replay ? messages.filter((message) => message.id !== replay.id) : messages });
78
85
  await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId, ...(probeId ? { probeId } : {}) });
79
86
  }
87
+ /**
88
+ * No Turn is running. "None" goes first: while the client still observes the
89
+ * stream it lost, it keeps its own partial answer over any snapshot that has
90
+ * no more parts, so the settled answer must arrive after it stops observing.
91
+ */
92
+ private async idle(connection: Connection, probeId: string | undefined, announceIdle: boolean): Promise<void> {
93
+ if (announceIdle) await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
94
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
95
+ }
80
96
  async acknowledge(connection: Connection, requestId: string): Promise<void> {
81
97
  const session = this.connections.get(connection.id);
82
98
  if (!session || session.source.requestId !== requestId || session.started) return;
@@ -595,6 +595,12 @@ export class PiChunkEncoder {
595
595
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
596
596
  turnStatus: terminal.turnStatus,
597
597
  ...(this.turnId ? { turnId: this.turnId } : {}),
598
+ // The browser keeps this stream copy over a history snapshot that
599
+ // arrives while it still observes the stream, so it must carry the
600
+ // user-stop marker the snapshot projects from `turn-aborted`.
601
+ ...(terminal.outcome === "aborted" && message.errorMessage === USER_STOP_REASON
602
+ ? { interruptedByUser: true }
603
+ : {}),
598
604
  ...((publicError ?? terminal.message) &&
599
605
  (terminal.outcome !== "aborted" ||
600
606
  message.errorMessage !== USER_STOP_REASON)
@@ -13,7 +13,7 @@ import type { SteerRepository } from "./steer-repository";
13
13
 
14
14
  /** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
15
15
  export class SubmissionFinalizer {
16
- /** User-stop markers appended after their terminal commit; `finish()` awaits its own. */
16
+ /** User-stop markers appended after their terminal commit; every `finish()` of that Turn awaits it. */
17
17
  private readonly stopMarkers = new Map<string, Promise<unknown>>();
18
18
  constructor(private readonly options: {
19
19
  submissions: SubmissionRepository;
@@ -36,7 +36,12 @@ export class SubmissionFinalizer {
36
36
  message?: string,
37
37
  ): Promise<StoredSubmission> {
38
38
  const initial = this.read(submissionId);
39
- if (isTerminalSubmissionStatus(initial.status)) return initial;
39
+ if (isTerminalSubmissionStatus(initial.status)) {
40
+ // A stop RPC and the ending harness finish the same Turn; whichever loses
41
+ // still waits for the stop marker, so its history broadcast carries it.
42
+ await this.stopMarkers.get(submissionId);
43
+ return initial;
44
+ }
40
45
  const aborted = Boolean(initial.abortReason) || outcome === "aborted";
41
46
  if (aborted) await this.options.releaseAbortedHarness();
42
47
  await finishPendingSteers({
@@ -58,8 +63,8 @@ export class SubmissionFinalizer {
58
63
  const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
59
64
  const marker = this.stopMarkers.get(submissionId);
60
65
  if (marker) {
61
- this.stopMarkers.delete(submissionId);
62
66
  await marker;
67
+ this.stopMarkers.delete(submissionId);
63
68
  }
64
69
  return completed;
65
70
  }