@nexrall/code-core 1.4.25 → 1.4.27

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.
@@ -801,417 +801,445 @@ async function streamChat(messages, options, onEvent) {
801
801
  const haveCompleteMessage = () => completedMessage !== null;
802
802
  // Partial input accumulator keyed by tool_use id
803
803
  const partialInputs = {};
804
- await new Promise((resolve, reject) => {
805
- // Feed chunks from the response body into the SSE parser.
806
- // Declared here so the heartbeat watchdog closure can reference it.
807
- const stream = response.body;
808
- // ── Heartbeat watchdog ────────────────────────────────────────────────────
809
- // The backend sends ": ping" comments every 10 s. If we receive no data at
810
- // all for 90 s the connection has silently dropped (proxy timeout, network
811
- // blip). Reject so the caller sees an actionable error instead of hanging.
812
- //
813
- // Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
814
- // The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
815
- // routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
816
- // laptop wakes, so a perfectly healthy connection got torn down and the turn
817
- // restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
818
- // while still detecting a truly dead socket faster in absolute terms than the
819
- // proxy's own idle timeout.
820
- const HEARTBEAT_TIMEOUT_MS = 90000;
821
- // Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
822
- // so the heartbeat above only catches a fully dead connection NOT an upstream model
823
- // stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
824
- // would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
825
- // model events (see parser below), never on pings, so a genuine stall trips this and
826
- // surfaces a retryable error.
827
- // Two-phase stall limits:
828
- // • BEFORE the first model event, the upstream is doing prompt processing (time-to-
829
- // first-token). On big contexts (1M window, cold prompt cache, large tool schemas)
830
- // that phase emits NOTHING until message_start and can legitimately exceed 150 s.
831
- // Killing it there was the "timeout 150s retry instantly works" bug: the first
832
- // attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
833
- // • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
834
- // is a genuine stall.
835
- // Overridable so tests can reach the stall path in seconds instead of minutes.
836
- // That path is where the subtlest bug in this file lives: `lastProgressAt` stops
837
- // advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
838
- // this watchdog — and only this one — can fire on a turn that already succeeded.
839
- const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
840
- const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
841
- let sawModelEvent = false;
842
- let lastDataAt = Date.now();
843
- let lastProgressAt = Date.now();
844
- const heartbeatWatchdog = setInterval(() => {
845
- const now = Date.now();
846
- // The turn already succeeded and we're only waiting on the trailing `done`.
847
- // Finish with what we have rather than discarding a complete message and
848
- // re-running the model.
804
+ // ─── Watchdog ownership ────────────────────────────────────────────────────
805
+ //
806
+ // Hoisted OUT of the promise executor so a single `finally` can guarantee the
807
+ // interval dies with the attempt. It used to be declared inside, and was cleared
808
+ // in nine separate branches — but not in the two `resolve()` paths inside the
809
+ // parser (the `[DONE]` sentinel and the `done` event), which are the NORMAL way a
810
+ // healthy turn ends. Those relied on `stream.on('end')` firing afterwards to do
811
+ // the cleanup; when a proxy or keep-alive holds the socket open, `end` never comes,
812
+ // so a 5-second interval outlived the turn. On a long run that is both a leak
813
+ // (one surviving timer per iteration, hundreds of iterations) and a correctness
814
+ // bug: the orphaned watchdog can still call stream.destroy() and reject() on an
815
+ // already-settled promise, i.e. tear down the NEXT thing using that socket.
816
+ //
817
+ // Nine clear sites are now belt-and-braces rather than load-bearing: clearInterval
818
+ // is idempotent, so leaving them costs nothing and each still stops the watchdog at
819
+ // the earliest possible moment.
820
+ let heartbeatWatchdog;
821
+ // NOTE: the promise body below is deliberately NOT re-indented for this `try`.
822
+ // Wrapping it cost one line; re-indenting ~400 lines would bury that one-line fix
823
+ // in a whitespace diff nobody can review.
824
+ try {
825
+ await new Promise((resolve, reject) => {
826
+ // Feed chunks from the response body into the SSE parser.
827
+ // Declared here so the heartbeat watchdog closure can reference it.
828
+ const stream = response.body;
829
+ // ── Heartbeat watchdog ────────────────────────────────────────────────────
830
+ // The backend sends ": ping" comments every 10 s. If we receive no data at
831
+ // all for 90 s the connection has silently dropped (proxy timeout, network
832
+ // blip). Reject so the caller sees an actionable error instead of hanging.
849
833
  //
850
- // The silence condition is essential, not decorative: the backend AWAITS its
851
- // billing write before emitting `balance_status` and `done`, so the gap after
852
- // `message_complete` is normal, expected, and can run for seconds on a slow DB.
853
- // Firing on the next tick regardless would tear down a perfectly healthy socket
854
- // and swallow the low-balance nudge trading the bug this guard was written to
855
- // fix for a different one. Wait out the full heartbeat timeout first; only then
856
- // is the trailing frame genuinely never coming.
857
- if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
858
- clearInterval(heartbeatWatchdog);
859
- stream.destroy?.();
860
- resolve();
861
- return;
862
- }
863
- if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
864
- clearInterval(heartbeatWatchdog);
865
- stream.destroy?.();
866
- // Same rule as the stall watchdog below: if nothing has been shown to the
867
- // caller yet, a fresh attempt can't duplicate output, so retry transparently
868
- // instead of surfacing a hard error. This is also the common path when the
869
- // machine just woke from sleep JS timers freeze during sleep, so on wake
870
- // `now - lastDataAt` immediately reads as a large gap even though nothing is
871
- // actually wrong with the connection; without this it used to hard-fail
872
- // instead of silently reconnecting.
873
- const recoverable = !emittedToCaller || !!allowRestartAfterRender;
874
- const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
875
- reject(tagTransient(new Error(recoverable
876
- ? `Connection lost no data received for ${secs} s. Reconnecting…`
877
- : `Connection lost — no data received for ${secs} s. Retry your message.`)));
878
- return;
879
- }
880
- const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
881
- if (now - lastProgressAt > stallLimitMs) {
882
- clearInterval(heartbeatWatchdog);
883
- stream.destroy?.();
884
- // Same rule as the heartbeat branch above, and MORE easily reached here: this
885
- // watchdog reads `lastProgressAt`, which only real model events bump. Once
886
- // `message_complete` lands, nothing bumps it again — so while the backend awaits
887
- // its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
888
- // branches above correctly stay silent) but THIS clock runs out and reports a
889
- // "stall" on a turn that already succeeded. Restarting there would discard a
890
- // correct answer and re-bill the model call, repeatedly. The turn is complete;
891
- // finish with it.
892
- if (haveCompleteMessage()) {
834
+ // Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
835
+ // The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
836
+ // routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
837
+ // laptop wakes, so a perfectly healthy connection got torn down and the turn
838
+ // restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
839
+ // while still detecting a truly dead socket faster in absolute terms than the
840
+ // proxy's own idle timeout.
841
+ const HEARTBEAT_TIMEOUT_MS = 90000;
842
+ // Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
843
+ // so the heartbeat above only catches a fully dead connection — NOT an upstream model
844
+ // stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
845
+ // would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
846
+ // model events (see parser below), never on pings, so a genuine stall trips this and
847
+ // surfaces a retryable error.
848
+ // Two-phase stall limits:
849
+ // • BEFORE the first model event, the upstream is doing prompt processing (time-to-
850
+ // first-token). On big contexts (1M window, cold prompt cache, large tool schemas)
851
+ // that phase emits NOTHING until message_start and can legitimately exceed 150 s.
852
+ // Killing it there was the "timeout 150s retry instantly works" bug: the first
853
+ // attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
854
+ // AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
855
+ // is a genuine stall.
856
+ // Overridable so tests can reach the stall path in seconds instead of minutes.
857
+ // That path is where the subtlest bug in this file lives: `lastProgressAt` stops
858
+ // advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
859
+ // this watchdog — and only this one — can fire on a turn that already succeeded.
860
+ const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
861
+ const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
862
+ let sawModelEvent = false;
863
+ let lastDataAt = Date.now();
864
+ let lastProgressAt = Date.now();
865
+ heartbeatWatchdog = setInterval(() => {
866
+ const now = Date.now();
867
+ // The turn already succeeded and we're only waiting on the trailing `done`.
868
+ // Finish with what we have rather than discarding a complete message and
869
+ // re-running the model.
870
+ //
871
+ // The silence condition is essential, not decorative: the backend AWAITS its
872
+ // billing write before emitting `balance_status` and `done`, so the gap after
873
+ // `message_complete` is normal, expected, and can run for seconds on a slow DB.
874
+ // Firing on the next tick regardless would tear down a perfectly healthy socket
875
+ // and swallow the low-balance nudge — trading the bug this guard was written to
876
+ // fix for a different one. Wait out the full heartbeat timeout first; only then
877
+ // is the trailing frame genuinely never coming.
878
+ if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
879
+ clearInterval(heartbeatWatchdog);
880
+ stream.destroy?.();
893
881
  resolve();
894
882
  return;
895
883
  }
896
- // If nothing has been shown to the caller yet, a fresh attempt can't duplicate
897
- // output — tag it retryable so the outer loop transparently retries the turn
898
- // instead of killing it. Once output has been emitted, a retry would duplicate
899
- // rendered text/tool calls, so surface the stall as a terminal error.
900
- reject(tagTransient(new Error(sawModelEvent
901
- ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
902
- : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
903
- return;
904
- }
905
- }, 5000);
906
- const parser = (0, eventsource_parser_1.createParser)((event) => {
907
- lastDataAt = Date.now(); // any SSE data (including ": ping" comments) resets the watchdog
908
- // Skip reconnect-interval events
909
- if (event.type !== 'event') {
910
- return;
911
- }
912
- {
913
- // Track the server-assigned frame id. This is the resume cursor: on a drop we
914
- // hand it back via Last-Event-ID and receive exactly the frames we missed,
915
- // instead of re-running the whole turn. Only present on resumable turns.
916
- const frameId = event.id;
917
- if (frameId) {
918
- const n = Number(frameId);
919
- if (Number.isFinite(n) && n > lastEventId)
920
- lastEventId = n;
921
- }
922
- const raw = event.data;
923
- if (!raw || raw === '[DONE]') {
924
- resolve();
884
+ if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
885
+ clearInterval(heartbeatWatchdog);
886
+ stream.destroy?.();
887
+ // Same rule as the stall watchdog below: if nothing has been shown to the
888
+ // caller yet, a fresh attempt can't duplicate output, so retry transparently
889
+ // instead of surfacing a hard error. This is also the common path when the
890
+ // machine just woke from sleep JS timers freeze during sleep, so on wake
891
+ // `now - lastDataAt` immediately reads as a large gap even though nothing is
892
+ // actually wrong with the connection; without this it used to hard-fail
893
+ // instead of silently reconnecting.
894
+ const recoverable = !emittedToCaller || !!allowRestartAfterRender;
895
+ const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
896
+ reject(tagTransient(new Error(recoverable
897
+ ? `Connection lost no data received for ${secs} s. Reconnecting…`
898
+ : `Connection lost — no data received for ${secs} s. Retry your message.`)));
925
899
  return;
926
900
  }
927
- let parsed;
928
- try {
929
- parsed = JSON.parse(raw);
930
- }
931
- catch {
932
- return; // Skip malformed lines
901
+ const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
902
+ if (now - lastProgressAt > stallLimitMs) {
903
+ clearInterval(heartbeatWatchdog);
904
+ stream.destroy?.();
905
+ // Same rule as the heartbeat branch above, and MORE easily reached here: this
906
+ // watchdog reads `lastProgressAt`, which only real model events bump. Once
907
+ // `message_complete` lands, nothing bumps it again — so while the backend awaits
908
+ // its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
909
+ // branches above correctly stay silent) but THIS clock runs out and reports a
910
+ // "stall" on a turn that already succeeded. Restarting there would discard a
911
+ // correct answer and re-bill the model call, repeatedly. The turn is complete;
912
+ // finish with it.
913
+ if (haveCompleteMessage()) {
914
+ resolve();
915
+ return;
916
+ }
917
+ // If nothing has been shown to the caller yet, a fresh attempt can't duplicate
918
+ // output — tag it retryable so the outer loop transparently retries the turn
919
+ // instead of killing it. Once output has been emitted, a retry would duplicate
920
+ // rendered text/tool calls, so surface the stall as a terminal error.
921
+ reject(tagTransient(new Error(sawModelEvent
922
+ ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
923
+ : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
924
+ return;
933
925
  }
934
- if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) {
926
+ }, 5000);
927
+ const parser = (0, eventsource_parser_1.createParser)((event) => {
928
+ lastDataAt = Date.now(); // any SSE data (including ": ping" comments) resets the watchdog
929
+ // Skip reconnect-interval events
930
+ if (event.type !== 'event') {
935
931
  return;
936
932
  }
937
- const evt = parsed;
938
- // Reaching here means a well-formed model event was parsed (": ping" keepalive
939
- // comments never produce a ParsedEvent with data) count it as real progress.
940
- lastProgressAt = Date.now();
941
- sawModelEvent = true;
942
- // Real data is flowing again — clear any "reconnecting…" indicator the caller
943
- // may be showing from an earlier retry on THIS same attempt.
944
- clearRetryIfNeeded();
945
- switch (evt.type) {
946
- case 'text': {
947
- const text = typeof evt.text === 'string' ? evt.text : '';
948
- textParts.push(text);
949
- emittedToCaller = true;
950
- emittedChars += text.length;
951
- emittedAnythingAcrossAttempts = true;
952
- emittedCharsAcrossAttempts += text.length;
953
- onEvent({ type: 'text', text });
954
- break;
933
+ {
934
+ // Track the server-assigned frame id. This is the resume cursor: on a drop we
935
+ // hand it back via Last-Event-ID and receive exactly the frames we missed,
936
+ // instead of re-running the whole turn. Only present on resumable turns.
937
+ const frameId = event.id;
938
+ if (frameId) {
939
+ const n = Number(frameId);
940
+ if (Number.isFinite(n) && n > lastEventId)
941
+ lastEventId = n;
955
942
  }
956
- case 'tool_use': {
957
- const block = {
958
- type: 'tool_use',
959
- id: typeof evt.id === 'string' ? evt.id : '',
960
- name: typeof evt.name === 'string' ? evt.name : '',
961
- input: evt.input ?? {},
962
- };
963
- // Handle streaming input deltas
964
- if (typeof evt.input_json_delta === 'string') {
965
- partialInputs[block.id] = (partialInputs[block.id] ?? '') + evt.input_json_delta;
966
- try {
967
- block.input = JSON.parse(partialInputs[block.id]);
968
- }
969
- catch {
970
- // Input not fully streamed yet
971
- return;
972
- }
973
- }
974
- toolUseBlocks.push(block);
975
- // Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
976
- // preview that no consumer renders. The agent loop's `tool_use` case is a
977
- // no-op and the UI's tool row is only drawn later, from `options.onToolUse`
978
- // during execution — which happens after streamChat() returns and so never
979
- // runs on a failed attempt. `emittedToCaller` is still set, conservatively,
980
- // so a restart continues to require an explicit rollback handler.
981
- emittedToCaller = true;
982
- emittedAnythingAcrossAttempts = true;
983
- onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
984
- break;
943
+ const raw = event.data;
944
+ if (!raw || raw === '[DONE]') {
945
+ resolve();
946
+ return;
985
947
  }
986
- case 'message_complete': {
987
- // Anthropic's stop_reason (and the full raw content array) travel inside
988
- // the nested `message` object the backend forwards verbatim (routes/code.js's
989
- // `sendEvent(res, {type:'message_complete', message})` — `message` is the raw
990
- // Anthropic SDK message with thinking already stripped, which always has
991
- // stop_reason). 'max_tokens' means the model's output was cut off mid-
992
- // generation — if the last content block is a tool_use, its `input` may be a
993
- // truncated JSON object that still happened to parse (e.g. a multi_edit whose
994
- // `edits` array lost its last, still-in-progress element, or came out empty/
995
- // missing entirely) without any error at all. The agent loop uses this to
996
- // refuse executing that block blindly instead of silently applying a partial edit.
997
- const nestedMessage = evt.message;
998
- const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
999
- // Build the complete assistant message from accumulated parts (the common
1000
- // path: text + tool_use, in the client's own order).
1001
- const contentBlocks = [];
1002
- const fullText = textParts.join('');
1003
- if (fullText) {
1004
- contentBlocks.push({ type: 'text', text: fullText });
1005
- }
1006
- contentBlocks.push(...toolUseBlocks);
1007
- // TOOL-SEARCH / SERVER-SIDE-BLOCK PRESERVATION.
1008
- // The rebuild above keeps ONLY text + tool_use. That is lossy for any turn
1009
- // that also contains server-side blocks — `server_tool_use`,
1010
- // `tool_search_tool_result` (and its nested `tool_reference`s), or a
1011
- // `web_search_tool_result`. Those blocks MUST round-trip back to the API
1012
- // verbatim on the next request or (a) the API rejects the follow-up (it
1013
- // expects the paired server_tool_use/result to be present) and (b) for tool
1014
- // search specifically, the discovered (deferred) tools are FORGOTTEN, forcing
1015
- // Claude to re-search every turn — the exact opposite of the token saving.
1016
- //
1017
- // Preserve server-side blocks (tool_search_tool_result / server_tool_use /
1018
- // web_search_tool_result) verbatim when present; otherwise keep the rebuilt
1019
- // array. See chooseFinalContent for the full rationale. Zero regression for
1020
- // an ordinary text/tool_use turn.
1021
- const rawContent = Array.isArray(nestedMessage?.content)
1022
- ? nestedMessage.content
1023
- : null;
1024
- const finalContent = chooseFinalContent(contentBlocks, rawContent);
1025
- completedMessage = {
1026
- role: 'assistant',
1027
- content: finalContent,
1028
- stopReason,
1029
- };
1030
- onEvent({ type: 'message_complete', message: completedMessage });
1031
- break;
948
+ let parsed;
949
+ try {
950
+ parsed = JSON.parse(raw);
1032
951
  }
1033
- case 'usage': {
1034
- if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
1035
- onEvent({
1036
- type: 'usage',
1037
- // Forwarded, not dropped: the backend tags the usage of an attempt it
1038
- // billed but never completed (its stream `abort` path). Without this
1039
- // the flag dies here and every consumer that sums usage silently folds
1040
- // a discarded attempt's tokens into the successful turn's total.
1041
- ...(evt.partial === true ? { partial: true } : {}),
1042
- // `replayed` means these tokens are being reported a SECOND time: the
1043
- // turn completed and was billed on an earlier attempt whose `done` never
1044
- // reached us, and the backend served this one from its idempotency cache
1045
- // instead of re-running the model. Nothing new was charged, so a cost
1046
- // display must not add them again.
1047
- ...(evt.replayed === true ? { replayed: true } : {}),
1048
- usage: {
1049
- input_tokens: evt.input_tokens,
1050
- output_tokens: evt.output_tokens,
1051
- cache_creation_input_tokens: typeof evt.cache_creation_input_tokens === 'number' ? evt.cache_creation_input_tokens : undefined,
1052
- cache_read_input_tokens: typeof evt.cache_read_input_tokens === 'number' ? evt.cache_read_input_tokens : undefined,
1053
- },
1054
- });
1055
- }
1056
- break;
952
+ catch {
953
+ return; // Skip malformed lines
1057
954
  }
1058
- case 'context_warning': {
1059
- // Server-side context-budget housekeeping (routes/code.js /
1060
- // contextWindow.js): it already safely trimmed/compacted the
1061
- // history before sending, at a turn boundary that never breaks
1062
- // a tool_use/tool_result pair. Purely informational — the
1063
- // server has already handled it, so there is nothing actionable
1064
- // for the user here. Deliberately NOT surfaced to the UI
1065
- // (used to be forwarded as an `error` event, which made a
1066
- // routine, already-handled trim look like a failure).
1067
- break;
955
+ if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) {
956
+ return;
1068
957
  }
1069
- case 'thinking': {
1070
- const text = typeof evt.text === 'string' ? evt.text : '';
1071
- if (text) {
958
+ const evt = parsed;
959
+ // Reaching here means a well-formed model event was parsed (": ping" keepalive
960
+ // comments never produce a ParsedEvent with data) — count it as real progress.
961
+ lastProgressAt = Date.now();
962
+ sawModelEvent = true;
963
+ // Real data is flowing again — clear any "reconnecting…" indicator the caller
964
+ // may be showing from an earlier retry on THIS same attempt.
965
+ clearRetryIfNeeded();
966
+ switch (evt.type) {
967
+ case 'text': {
968
+ const text = typeof evt.text === 'string' ? evt.text : '';
969
+ textParts.push(text);
1072
970
  emittedToCaller = true;
1073
971
  emittedChars += text.length;
1074
972
  emittedAnythingAcrossAttempts = true;
1075
973
  emittedCharsAcrossAttempts += text.length;
1076
- onEvent({ type: 'thinking', text });
974
+ onEvent({ type: 'text', text });
975
+ break;
1077
976
  }
1078
- break;
1079
- }
1080
- case 'thinking_progress': {
1081
- // Without this case the live token counter never updated and long thinking
1082
- // phases (e.g. after reading a large file) looked frozen on "Thinking…".
1083
- const tokens = typeof evt.tokens === 'number' ? evt.tokens : 0;
1084
- onEvent({ type: 'thinking_progress', tokens });
1085
- break;
1086
- }
1087
- case 'thinking_delta': {
1088
- // Live thinking text streamed token-by-token so a long thinking phase shows
1089
- // content as it forms, not a single block dumped at message completion.
1090
- const text = typeof evt.text === 'string' ? evt.text : '';
1091
- if (text) {
977
+ case 'tool_use': {
978
+ const block = {
979
+ type: 'tool_use',
980
+ id: typeof evt.id === 'string' ? evt.id : '',
981
+ name: typeof evt.name === 'string' ? evt.name : '',
982
+ input: evt.input ?? {},
983
+ };
984
+ // Handle streaming input deltas
985
+ if (typeof evt.input_json_delta === 'string') {
986
+ partialInputs[block.id] = (partialInputs[block.id] ?? '') + evt.input_json_delta;
987
+ try {
988
+ block.input = JSON.parse(partialInputs[block.id]);
989
+ }
990
+ catch {
991
+ // Input not fully streamed yet
992
+ return;
993
+ }
994
+ }
995
+ toolUseBlocks.push(block);
996
+ // Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
997
+ // preview that no consumer renders. The agent loop's `tool_use` case is a
998
+ // no-op and the UI's tool row is only drawn later, from `options.onToolUse`
999
+ // during execution — which happens after streamChat() returns and so never
1000
+ // runs on a failed attempt. `emittedToCaller` is still set, conservatively,
1001
+ // so a restart continues to require an explicit rollback handler.
1092
1002
  emittedToCaller = true;
1093
- emittedChars += text.length;
1094
1003
  emittedAnythingAcrossAttempts = true;
1095
- emittedCharsAcrossAttempts += text.length;
1096
- onEvent({ type: 'thinking_delta', text });
1004
+ onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
1005
+ break;
1097
1006
  }
1098
- break;
1099
- }
1100
- case 'resumable': {
1101
- // The backend numbers this turn's frames and buffers them, so a dropped
1102
- // connection can reattach mid-response rather than re-running the turn.
1103
- // Purely a capability announcement nothing to show the user.
1104
- serverResumable = true;
1105
- break;
1106
- }
1107
- case 'balance_status': {
1108
- const balance = typeof evt.balance === 'number' ? evt.balance : 0;
1109
- const zero = !!evt.zero;
1110
- onEvent({ type: 'balance_status', balance, zero });
1111
- break;
1112
- }
1113
- case 'done': {
1114
- onEvent({ type: 'done' });
1115
- resolve();
1116
- break;
1117
- }
1118
- case 'error': {
1119
- const message = typeof evt.message === 'string' ? evt.message :
1120
- typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
1121
- // The GET /chat/resume tail loop sends this when the turn's Redis frame
1122
- // buffer developed a gap (trimmed window, a swallowed flush error, or the
1123
- // owning pod died) or the turn is simply gone — see routes/code.js. Unlike
1124
- // `isRetryableStreamMsg` below, this is NOT a fuzzy text match: the backend
1125
- // sets the flag explicitly, so it can never silently stop matching if either
1126
- // side rewrites the English message. There is no cursor left to resume
1127
- // froma plain "retryable" resume-with-Last-Event-ID would just 410 again
1128
- // — so this always needs a fresh turnId, which the outer loop mints via
1129
- // `forceRestart`.
1130
- const notResumable = evt.notResumable === true;
1131
- // Transient upstream failure before any output let the outer loop retry it
1132
- // transparently instead of killing the turn (this is the "Overloaded" case).
1133
- // But never retry once the turn's message has already been delivered in
1134
- // full: a late error frame (e.g. the backend's billing write failing after
1135
- // `message_complete`) must not discard a correct answer and re-bill the
1136
- // model call. Surface it as a notice and finish with what we have.
1137
- if (haveCompleteMessage()) {
1138
- clearInterval(heartbeatWatchdog);
1139
- onEvent({ type: 'error', message });
1140
- resolve();
1007
+ case 'message_complete': {
1008
+ // Anthropic's stop_reason (and the full raw content array) travel inside
1009
+ // the nested `message` object the backend forwards verbatim (routes/code.js's
1010
+ // `sendEvent(res, {type:'message_complete', message})` `message` is the raw
1011
+ // Anthropic SDK message with thinking already stripped, which always has
1012
+ // stop_reason). 'max_tokens' means the model's output was cut off mid-
1013
+ // generation — if the last content block is a tool_use, its `input` may be a
1014
+ // truncated JSON object that still happened to parse (e.g. a multi_edit whose
1015
+ // `edits` array lost its last, still-in-progress element, or came out empty/
1016
+ // missing entirely) without any error at all. The agent loop uses this to
1017
+ // refuse executing that block blindly instead of silently applying a partial edit.
1018
+ const nestedMessage = evt.message;
1019
+ const stopReason = typeof nestedMessage?.stop_reason === 'string' ? nestedMessage.stop_reason : null;
1020
+ // Build the complete assistant message from accumulated parts (the common
1021
+ // path: text + tool_use, in the client's own order).
1022
+ const contentBlocks = [];
1023
+ const fullText = textParts.join('');
1024
+ if (fullText) {
1025
+ contentBlocks.push({ type: 'text', text: fullText });
1026
+ }
1027
+ contentBlocks.push(...toolUseBlocks);
1028
+ // TOOL-SEARCH / SERVER-SIDE-BLOCK PRESERVATION.
1029
+ // The rebuild above keeps ONLY text + tool_use. That is lossy for any turn
1030
+ // that also contains server-side blocks `server_tool_use`,
1031
+ // `tool_search_tool_result` (and its nested `tool_reference`s), or a
1032
+ // `web_search_tool_result`. Those blocks MUST round-trip back to the API
1033
+ // verbatim on the next request or (a) the API rejects the follow-up (it
1034
+ // expects the paired server_tool_use/result to be present) and (b) for tool
1035
+ // search specifically, the discovered (deferred) tools are FORGOTTEN, forcing
1036
+ // Claude to re-search every turn the exact opposite of the token saving.
1037
+ //
1038
+ // Preserve server-side blocks (tool_search_tool_result / server_tool_use /
1039
+ // web_search_tool_result) verbatim when present; otherwise keep the rebuilt
1040
+ // array. See chooseFinalContent for the full rationale. Zero regression for
1041
+ // an ordinary text/tool_use turn.
1042
+ const rawContent = Array.isArray(nestedMessage?.content)
1043
+ ? nestedMessage.content
1044
+ : null;
1045
+ const finalContent = chooseFinalContent(contentBlocks, rawContent);
1046
+ completedMessage = {
1047
+ role: 'assistant',
1048
+ content: finalContent,
1049
+ stopReason,
1050
+ };
1051
+ onEvent({ type: 'message_complete', message: completedMessage });
1052
+ break;
1053
+ }
1054
+ case 'usage': {
1055
+ if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
1056
+ onEvent({
1057
+ type: 'usage',
1058
+ // Forwarded, not dropped: the backend tags the usage of an attempt it
1059
+ // billed but never completed (its stream `abort` path). Without this
1060
+ // the flag dies here and every consumer that sums usage silently folds
1061
+ // a discarded attempt's tokens into the successful turn's total.
1062
+ ...(evt.partial === true ? { partial: true } : {}),
1063
+ // `replayed` means these tokens are being reported a SECOND time: the
1064
+ // turn completed and was billed on an earlier attempt whose `done` never
1065
+ // reached us, and the backend served this one from its idempotency cache
1066
+ // instead of re-running the model. Nothing new was charged, so a cost
1067
+ // display must not add them again.
1068
+ ...(evt.replayed === true ? { replayed: true } : {}),
1069
+ usage: {
1070
+ input_tokens: evt.input_tokens,
1071
+ output_tokens: evt.output_tokens,
1072
+ cache_creation_input_tokens: typeof evt.cache_creation_input_tokens === 'number' ? evt.cache_creation_input_tokens : undefined,
1073
+ cache_read_input_tokens: typeof evt.cache_read_input_tokens === 'number' ? evt.cache_read_input_tokens : undefined,
1074
+ },
1075
+ });
1076
+ }
1077
+ break;
1078
+ }
1079
+ case 'context_warning': {
1080
+ // Server-side context-budget housekeeping (routes/code.js /
1081
+ // contextWindow.js): it already safely trimmed/compacted the
1082
+ // history before sending, at a turn boundary that never breaks
1083
+ // a tool_use/tool_result pair. Purely informational — the
1084
+ // server has already handled it, so there is nothing actionable
1085
+ // for the user here. Deliberately NOT surfaced to the UI
1086
+ // (used to be forwarded as an `error` event, which made a
1087
+ // routine, already-handled trim look like a failure).
1088
+ break;
1089
+ }
1090
+ case 'thinking': {
1091
+ const text = typeof evt.text === 'string' ? evt.text : '';
1092
+ if (text) {
1093
+ emittedToCaller = true;
1094
+ emittedChars += text.length;
1095
+ emittedAnythingAcrossAttempts = true;
1096
+ emittedCharsAcrossAttempts += text.length;
1097
+ onEvent({ type: 'thinking', text });
1098
+ }
1099
+ break;
1100
+ }
1101
+ case 'thinking_progress': {
1102
+ // Without this case the live token counter never updated and long thinking
1103
+ // phases (e.g. after reading a large file) looked frozen on "Thinking…".
1104
+ const tokens = typeof evt.tokens === 'number' ? evt.tokens : 0;
1105
+ onEvent({ type: 'thinking_progress', tokens });
1106
+ break;
1141
1107
  }
1142
- else if (notResumable && (!emittedToCaller || allowRestartAfterRender)) {
1143
- clearInterval(heartbeatWatchdog);
1144
- stream.destroy?.();
1145
- reject(Object.assign(tagTransient(new Error(message)), { forceRestart: true }));
1108
+ case 'thinking_delta': {
1109
+ // Live thinking text streamed token-by-token so a long thinking phase shows
1110
+ // content as it forms, not a single block dumped at message completion.
1111
+ const text = typeof evt.text === 'string' ? evt.text : '';
1112
+ if (text) {
1113
+ emittedToCaller = true;
1114
+ emittedChars += text.length;
1115
+ emittedAnythingAcrossAttempts = true;
1116
+ emittedCharsAcrossAttempts += text.length;
1117
+ onEvent({ type: 'thinking_delta', text });
1118
+ }
1119
+ break;
1146
1120
  }
1147
- else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
1148
- clearInterval(heartbeatWatchdog);
1149
- stream.destroy?.();
1150
- reject(tagTransient(new Error(message)));
1121
+ case 'resumable': {
1122
+ // The backend numbers this turn's frames and buffers them, so a dropped
1123
+ // connection can reattach mid-response rather than re-running the turn.
1124
+ // Purely a capability announcement — nothing to show the user.
1125
+ serverResumable = true;
1126
+ break;
1151
1127
  }
1152
- else {
1153
- // Terminal error. The three branches above each stop the watchdog
1154
- // and tear the socket down; this one used to do neither, so a
1155
- // fatal SSE error left a 5-second interval running and the
1156
- // response body open for the lifetime of the process. The
1157
- // rejection settles the promise either way, which is exactly why
1158
- // the leak was invisible — nothing downstream ever noticed, it
1159
- // just accumulated one timer per failed turn across a long session.
1160
- clearInterval(heartbeatWatchdog);
1161
- stream.destroy?.();
1162
- onEvent({ type: 'error', message });
1163
- reject(new Error(message));
1128
+ case 'balance_status': {
1129
+ const balance = typeof evt.balance === 'number' ? evt.balance : 0;
1130
+ const zero = !!evt.zero;
1131
+ onEvent({ type: 'balance_status', balance, zero });
1132
+ break;
1133
+ }
1134
+ case 'done': {
1135
+ onEvent({ type: 'done' });
1136
+ resolve();
1137
+ break;
1138
+ }
1139
+ case 'error': {
1140
+ const message = typeof evt.message === 'string' ? evt.message :
1141
+ typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
1142
+ // The GET /chat/resume tail loop sends this when the turn's Redis frame
1143
+ // buffer developed a gap (trimmed window, a swallowed flush error, or the
1144
+ // owning pod died) or the turn is simply gone — see routes/code.js. Unlike
1145
+ // `isRetryableStreamMsg` below, this is NOT a fuzzy text match: the backend
1146
+ // sets the flag explicitly, so it can never silently stop matching if either
1147
+ // side rewrites the English message. There is no cursor left to resume
1148
+ // from — a plain "retryable" resume-with-Last-Event-ID would just 410 again
1149
+ // — so this always needs a fresh turnId, which the outer loop mints via
1150
+ // `forceRestart`.
1151
+ const notResumable = evt.notResumable === true;
1152
+ // Transient upstream failure before any output → let the outer loop retry it
1153
+ // transparently instead of killing the turn (this is the "Overloaded" case).
1154
+ // But never retry once the turn's message has already been delivered in
1155
+ // full: a late error frame (e.g. the backend's billing write failing after
1156
+ // `message_complete`) must not discard a correct answer and re-bill the
1157
+ // model call. Surface it as a notice and finish with what we have.
1158
+ if (haveCompleteMessage()) {
1159
+ clearInterval(heartbeatWatchdog);
1160
+ onEvent({ type: 'error', message });
1161
+ resolve();
1162
+ }
1163
+ else if (notResumable && (!emittedToCaller || allowRestartAfterRender)) {
1164
+ clearInterval(heartbeatWatchdog);
1165
+ stream.destroy?.();
1166
+ reject(Object.assign(tagTransient(new Error(message)), { forceRestart: true }));
1167
+ }
1168
+ else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
1169
+ clearInterval(heartbeatWatchdog);
1170
+ stream.destroy?.();
1171
+ reject(tagTransient(new Error(message)));
1172
+ }
1173
+ else {
1174
+ // Terminal error. The three branches above each stop the watchdog
1175
+ // and tear the socket down; this one used to do neither, so a
1176
+ // fatal SSE error left a 5-second interval running and the
1177
+ // response body open for the lifetime of the process. The
1178
+ // rejection settles the promise either way, which is exactly why
1179
+ // the leak was invisible — nothing downstream ever noticed, it
1180
+ // just accumulated one timer per failed turn across a long session.
1181
+ clearInterval(heartbeatWatchdog);
1182
+ stream.destroy?.();
1183
+ onEvent({ type: 'error', message });
1184
+ reject(new Error(message));
1185
+ }
1186
+ break;
1164
1187
  }
1165
- break;
1166
1188
  }
1167
1189
  }
1168
- }
1169
- });
1170
- stream.on('data', (chunk) => {
1171
- lastDataAt = Date.now();
1172
- if (abortSignal?.aborted) {
1173
- stream.destroy?.();
1174
- return;
1175
- }
1176
- parser.feed(chunk.toString('utf-8'));
1177
- });
1178
- stream.on('end', () => {
1179
- clearInterval(heartbeatWatchdog);
1180
- // A clean 'end' with ZERO model events means the connection was dropped before
1181
- // the backend produced anything (deploy restart, proxy reset). Without this,
1182
- // the fallback below returns an EMPTY assistant message and the agent loop
1183
- // treats it as a silent no-op the turn just vanishes. Nothing was emitted,
1184
- // so a retry cannot duplicate output → tag retryable.
1185
- if (!sawModelEvent && !completedMessage) {
1186
- reject(Object.assign(new Error('Connection closed before the model responded. Retrying…'), { retryable: true }));
1187
- return;
1188
- }
1189
- resolve();
1190
- });
1191
- // Treat stream destruction from abort as a clean exit, not an error
1192
- stream.on('error', (err) => {
1193
- clearInterval(heartbeatWatchdog);
1194
- if (abortSignal?.aborted || controller.signal.aborted) {
1195
- resolve();
1196
- return;
1197
- }
1198
- // Socket died AFTER the turn was fully delivered — the only thing still
1199
- // outstanding was the trailing `done`. Treat as success: rejecting here would
1200
- // throw away a complete answer and (with restart enabled) re-run and re-bill
1201
- // the entire turn. This is the widest such window in practice, because the
1202
- // backend does a DB write between `message_complete` and `done`.
1203
- if (haveCompleteMessage()) {
1190
+ });
1191
+ stream.on('data', (chunk) => {
1192
+ lastDataAt = Date.now();
1193
+ if (abortSignal?.aborted) {
1194
+ stream.destroy?.();
1195
+ return;
1196
+ }
1197
+ parser.feed(chunk.toString('utf-8'));
1198
+ });
1199
+ stream.on('end', () => {
1200
+ clearInterval(heartbeatWatchdog);
1201
+ // A clean 'end' with ZERO model events means the connection was dropped before
1202
+ // the backend produced anything (deploy restart, proxy reset). Without this,
1203
+ // the fallback below returns an EMPTY assistant message and the agent loop
1204
+ // treats it as a silent no-op the turn just vanishes. Nothing was emitted,
1205
+ // so a retry cannot duplicate output tag retryable.
1206
+ if (!sawModelEvent && !completedMessage) {
1207
+ reject(Object.assign(new Error('Connection closed before the model responded. Retrying…'), { retryable: true }));
1208
+ return;
1209
+ }
1204
1210
  resolve();
1205
- return;
1206
- }
1207
- // Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
1208
- // happen whenever the backend restarts mid-deploy or a proxy drops the socket.
1209
- // They are exactly as transient as an overloaded_error — if nothing has been
1210
- // emitted to the caller yet, retry the attempt transparently instead of
1211
- // surfacing "Stream failed: Premature close" and killing the whole turn.
1212
- reject(tagTransient(err));
1211
+ });
1212
+ // Treat stream destruction from abort as a clean exit, not an error
1213
+ stream.on('error', (err) => {
1214
+ clearInterval(heartbeatWatchdog);
1215
+ if (abortSignal?.aborted || controller.signal.aborted) {
1216
+ resolve();
1217
+ return;
1218
+ }
1219
+ // Socket died AFTER the turn was fully delivered — the only thing still
1220
+ // outstanding was the trailing `done`. Treat as success: rejecting here would
1221
+ // throw away a complete answer and (with restart enabled) re-run and re-bill
1222
+ // the entire turn. This is the widest such window in practice, because the
1223
+ // backend does a DB write between `message_complete` and `done`.
1224
+ if (haveCompleteMessage()) {
1225
+ resolve();
1226
+ return;
1227
+ }
1228
+ // Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
1229
+ // happen whenever the backend restarts mid-deploy or a proxy drops the socket.
1230
+ // They are exactly as transient as an overloaded_error — if nothing has been
1231
+ // emitted to the caller yet, retry the attempt transparently instead of
1232
+ // surfacing "Stream failed: Premature close" and killing the whole turn.
1233
+ reject(tagTransient(err));
1234
+ });
1213
1235
  });
1214
- });
1236
+ }
1237
+ finally {
1238
+ // The one guaranteed cleanup: covers the two parser `resolve()` paths that
1239
+ // clear nothing, plus any future exit path added without remembering to.
1240
+ if (heartbeatWatchdog !== undefined)
1241
+ clearInterval(heartbeatWatchdog);
1242
+ }
1215
1243
  if (completedMessage) {
1216
1244
  return completedMessage;
1217
1245
  }