@agent-native/core 0.161.12 → 0.161.16

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.
Files changed (47) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/clear-selected-dashboard-object.ts +71 -0
  3. package/corpus/templates/analytics/actions/view-screen.ts +68 -4
  4. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -66
  5. package/corpus/templates/analytics/app/lib/selected-object.ts +53 -0
  6. package/corpus/templates/analytics/app/pages/Ask.tsx +21 -0
  7. package/corpus/templates/analytics/changelog/2026-08-18-ask-no-stale-dashboard-context.md +6 -0
  8. package/corpus/templates/calendar/app/root.tsx +12 -4
  9. package/corpus/templates/calendar/changelog/2026-08-18-calendar-stays-responsive-in-the-desktop-app-without-focus-t.md +6 -0
  10. package/corpus/templates/clips/app/components/library/recording-card.tsx +19 -3
  11. package/corpus/templates/clips/app/hooks/use-recording-leave-guard.ts +70 -0
  12. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  13. package/corpus/templates/clips/app/routes/record.tsx +42 -0
  14. package/corpus/templates/clips/changelog/2026-08-18-deleting-a-recording-no-longer-freezes-clips.md +6 -0
  15. package/corpus/templates/clips/desktop/src/app.tsx +9 -10
  16. package/corpus/templates/clips/desktop/src/lib/recorder.ts +4 -3
  17. package/corpus/templates/clips/desktop/src/lib/recording-start-guard.ts +26 -0
  18. package/corpus/templates/dispatch/app/chat-first-pages.ts +40 -40
  19. package/corpus/templates/dispatch/app/i18n-data.ts +5 -5
  20. package/corpus/templates/factory/AGENTS.md +30 -29
  21. package/corpus/templates/slides/app/root.tsx +21 -0
  22. package/corpus/templates/slides/server/handlers/decks.ts +7 -3
  23. package/corpus/templates/slides/server/handlers/request-auth-context.ts +58 -1
  24. package/corpus/templates/slides/server/handlers/share.ts +7 -2
  25. package/corpus/templates/slides/server/handlers/uploads.ts +7 -2
  26. package/dist/action.js +49 -0
  27. package/dist/agent/engine/translate-anthropic.js +19 -1
  28. package/dist/agent/engine/types.d.ts +7 -0
  29. package/dist/agent/production-agent.js +32 -0
  30. package/dist/agent/thread-data-builder.d.ts +16 -4
  31. package/dist/agent/thread-data-builder.js +159 -5
  32. package/dist/client/AssistantChat.js +1 -0
  33. package/dist/client/agent-chat-adapter.js +13 -1
  34. package/dist/client/chat-first/app-open-actions.js +1 -1
  35. package/dist/client/chat-first/copy.js +2 -2
  36. package/dist/client/mcp-apps/McpAppRenderer.js +7 -2
  37. package/dist/collab/struct-routes.d.ts +1 -1
  38. package/dist/mcp/embed-app.js +71 -2
  39. package/dist/notifications/routes.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +2 -2
  41. package/dist/secrets/routes.d.ts +6 -6
  42. package/dist/server/agent-teams.js +3 -1
  43. package/dist/server/cors-origins.js +24 -1
  44. package/dist/server/realtime-token.d.ts +1 -1
  45. package/dist/styles/agent-conversation.css +25 -4
  46. package/dist/transcription/builder-transcription.js +12 -1
  47. package/package.json +1 -1
package/dist/action.js CHANGED
@@ -349,6 +349,50 @@ const SUBSCHEMA_MAP_KEYS = [
349
349
  * `default`, `const`, `enum`, or `examples`, whose objects may legitimately
350
350
  * contain a `propertyNames` data key that must be preserved.
351
351
  */
352
+ /**
353
+ * A schema position with no `type` — what Zod emits for `z.unknown()` /
354
+ * `z.any()` — is rejected by OpenAI with "schema must have a 'type' key",
355
+ * 400ing the whole request exactly like `oneOf` did. There are 137 such sites
356
+ * across the templates, so this has to be answered at the boundary rather than
357
+ * by retyping every action.
358
+ *
359
+ * Expressed as a union of concrete JSON types because that shape is already
360
+ * proven against the live validator: `setFilterDefault.value`, a
361
+ * `z.union([string, number, boolean, null])` sitting in the same tool schema,
362
+ * was never flagged while the typeless sibling next to it was. `true` for
363
+ * `additionalProperties` is a boolean, not a subschema, so it needs no type of
364
+ * its own; array items get one bounded level rather than recursing forever.
365
+ */
366
+ const JSON_VALUE_BRANCHES = [
367
+ { type: "string" },
368
+ { type: "number" },
369
+ { type: "boolean" },
370
+ { type: "null" },
371
+ { type: "object", additionalProperties: true },
372
+ {
373
+ type: "array",
374
+ items: {
375
+ anyOf: [
376
+ { type: "string" },
377
+ { type: "number" },
378
+ { type: "boolean" },
379
+ { type: "null" },
380
+ { type: "object", additionalProperties: true },
381
+ ],
382
+ },
383
+ },
384
+ ];
385
+ /** Keywords that make a `type`-less schema meaningful on their own. */
386
+ const TYPE_SUBSTITUTE_KEYS = [
387
+ "type",
388
+ "anyOf",
389
+ "oneOf",
390
+ "allOf",
391
+ "enum",
392
+ "const",
393
+ "$ref",
394
+ "not",
395
+ ];
352
396
  function stripUnsupportedSchemaKeywords(node) {
353
397
  if (!node || typeof node !== "object" || Array.isArray(node))
354
398
  return node;
@@ -388,6 +432,11 @@ function stripUnsupportedSchemaKeywords(node) {
388
432
  obj.anyOf = [...existing, ...obj.oneOf];
389
433
  delete obj.oneOf;
390
434
  }
435
+ // Last, so a node that only became typed above (oneOf -> anyOf) is not also
436
+ // given a redundant value union.
437
+ if (!TYPE_SUBSTITUTE_KEYS.some((key) => obj[key] !== undefined)) {
438
+ obj.anyOf = JSON_VALUE_BRANCHES.map((branch) => ({ ...branch }));
439
+ }
391
440
  return node;
392
441
  }
393
442
  /**
@@ -343,6 +343,11 @@ function enginePartToAnthropic(part, builderGateway) {
343
343
  };
344
344
  }
345
345
  case "thinking":
346
+ // A redacted block has no readable thinking or signature — only its
347
+ // encrypted payload, which Anthropic requires back unmodified.
348
+ if (part.redactedData) {
349
+ return { type: "redacted_thinking", data: part.redactedData };
350
+ }
346
351
  // Anthropic thinking blocks — pass through with signature for context window continuity
347
352
  return {
348
353
  type: "thinking",
@@ -409,7 +414,20 @@ export function anthropicContentToEngine(content) {
409
414
  signature: b.signature,
410
415
  };
411
416
  }
412
- // Unknown block type — skip
417
+ if (block.type === "redacted_thinking") {
418
+ // Dropping this looked like "skip an unreadable block", but Anthropic
419
+ // requires the whole thinking sequence back verbatim within a tool-use
420
+ // turn: losing it makes the very next loop iteration send an assistant
421
+ // turn the API refuses, from a turn that streamed perfectly.
422
+ return {
423
+ type: "thinking",
424
+ text: "",
425
+ redactedData: block.data ?? "",
426
+ };
427
+ }
428
+ // Unknown block type. Skipping is the only safe replay, but a silent skip
429
+ // is how redacted_thinking went missing — say which type was dropped.
430
+ console.warn(`[anthropic-engine] dropping unrecognized content block type "${block.type}" from the assistant turn; it will not be replayed`);
413
431
  return { type: "text", text: "" };
414
432
  })
415
433
  .filter((p) => !(p.type === "text" && p.text === ""));
@@ -121,6 +121,13 @@ export interface EngineThinkingPart {
121
121
  text: string;
122
122
  /** Opaque signature for pass-through on next turn (Anthropic extended thinking) */
123
123
  signature?: string;
124
+ /**
125
+ * Encrypted payload of an Anthropic `redacted_thinking` block, which carries
126
+ * no readable `text` or `signature`. Anthropic requires the block back
127
+ * verbatim in the same tool-use turn, so it has to survive normalization —
128
+ * a part with this set replays as `redacted_thinking`, not as `thinking`.
129
+ */
130
+ redactedData?: string;
124
131
  }
125
132
  export type EngineContentPart = EngineTextPart | EngineImagePart | EngineFilePart | EngineToolCallPart | EngineToolResultPart | EngineThinkingPart;
126
133
  export type EngineMessage = {
@@ -772,6 +772,15 @@ const RUN_BUDGET_EXHAUSTED_ERROR_CODE = "run_budget_exhausted";
772
772
  const RUN_BUDGET_EXHAUSTED_MESSAGE = "I ran out of time before finishing this step. " +
773
773
  "I stopped rather than keep retrying silently. " +
774
774
  "Check any completed tool cards above before retrying, ideally as one smaller follow-up.";
775
+ /**
776
+ * Text attachments have been capped since forever; binary ones never were, so
777
+ * a large PDF or screenshot went to the provider as unbounded inline base64.
778
+ * OpenAI rejects the whole request over 1,048,576 chars in one `file_url`
779
+ * ("string too long", measured at 4,149,128), which kills the turn — the cap is
780
+ * on the encoded string, so that is what this counts rather than decoded bytes.
781
+ * Held under the limit to leave room for the `data:<mediaType>;base64,` prefix.
782
+ */
783
+ const MAX_INLINE_ATTACHMENT_BASE64_CHARS = 1_000_000;
775
784
  const MAX_TEXT_ATTACHMENT_CHARS = 60_000;
776
785
  const MAX_TEXT_ATTACHMENTS_TOTAL_CHARS = 80_000;
777
786
  const MAX_SELECTION_CONTEXT_CHARS = 8_000;
@@ -1093,6 +1102,17 @@ export function buildUserContentWithAttachments(opts) {
1093
1102
  continue;
1094
1103
  }
1095
1104
  const match = att.data.match(/^data:(image\/[^;]+);base64,(.+)$/);
1105
+ if (match &&
1106
+ isSupportedImageMediaType(match[1]) &&
1107
+ match[2].length > MAX_INLINE_ATTACHMENT_BASE64_CHARS) {
1108
+ // The upload already happened and `uploadedUrl` is the whole point of
1109
+ // it. Inlining the bytes anyway is what made the request unsendable.
1110
+ const label = att.name ? `"${att.name}"` : "An image";
1111
+ textAttachments.push(uploadedUrl
1112
+ ? `[${label} was uploaded to ${uploadedUrl}. It was too large to send inline for vision analysis, so use the URL for embedding/reference.]`
1113
+ : `[${label} was too large to send inline for vision analysis and no upload URL is available. Ask the user to attach a smaller image.]`);
1114
+ continue;
1115
+ }
1096
1116
  if (match && isSupportedImageMediaType(match[1])) {
1097
1117
  userContent.push({
1098
1118
  type: "image",
@@ -1125,6 +1145,13 @@ export function buildUserContentWithAttachments(opts) {
1125
1145
  }
1126
1146
  const filePart = dataUrlToFilePart(att);
1127
1147
  if (filePart) {
1148
+ if (filePart.data.length > MAX_INLINE_ATTACHMENT_BASE64_CHARS) {
1149
+ const label = att.name ? `"${att.name}"` : "A file";
1150
+ textAttachments.push(uploadedUrl
1151
+ ? `[${label} was uploaded to ${uploadedUrl}. It was too large to send inline, so read it from the URL if its contents are needed.]`
1152
+ : `[${label} was too large to send inline and no upload URL is available. Ask the user for a smaller file.]`);
1153
+ continue;
1154
+ }
1128
1155
  userContent.push(filePart);
1129
1156
  continue;
1130
1157
  }
@@ -7161,6 +7188,11 @@ export function createProductionAgentHandler(options) {
7161
7188
  const { threadDataToEngineMessages } = await import("./thread-data-builder.js");
7162
7189
  const priorThreadData = (await getThread(effectiveThreadId))
7163
7190
  ?.threadData;
7191
+ // `threadDataToEngineMessages` takes one argument and flattens tool
7192
+ // calls to text by design. The second argument never existed anywhere
7193
+ // in the repo, so this failed `tsc` and broke every production build
7194
+ // between 16:29 and now -- it reached main only because admin merges
7195
+ // do not wait for the typecheck that would have caught it.
7164
7196
  const resumed = threadDataToEngineMessages(priorThreadData);
7165
7197
  if (resumed.length > 0) {
7166
7198
  const actionPreparationTool = typeof backgroundRunMarker?.actionPreparationTool === "string" &&
@@ -62,17 +62,29 @@ export declare function buildAssistantMessage(events: RunEvent[], runId?: string
62
62
  * fail with "Parent message not found".
63
63
  */
64
64
  export declare function normalizeThreadRepository(repo: any): any;
65
+ export interface ThreadDataToEngineMessagesOptions {
66
+ /**
67
+ * Replay the tool calls and results thread_data already stores instead of
68
+ * flattening each turn to its prose. Required by callers that RESUME a run
69
+ * (chained background continuation, agent-teams continue): a turn rebuilt as
70
+ * text alone tells the model what it said but not what it did, so it re-runs
71
+ * tools it already ran and cannot see their output. Callers that only need
72
+ * "what was said" — recovery floors, memory compaction — leave this off.
73
+ */
74
+ includeToolCalls?: boolean;
75
+ }
65
76
  /**
66
77
  * Rebuild a flat `EngineMessage[]` from persisted thread_data (the
67
- * assistant-ui ExportedMessageRepository shape). Text-only — tool calls/results
68
- * are flattened to their text so a continuation run gets the conversation
69
- * prefix as plain context (Anthropic's prompt cache makes the resume cheap).
78
+ * assistant-ui ExportedMessageRepository shape). Each turn collapses to its
79
+ * text by default, which is all a recovery floor or a memory compaction needs.
80
+ * Callers resuming a run pass `includeToolCalls` to replay what the turn
81
+ * actually DID as well as what it said.
70
82
  *
71
83
  * Used to resume a background sub-agent in a fresh function invocation (the
72
84
  * server-side analog of the browser re-POSTing history for the main chat).
73
85
  * Originally inlined in `integrations/webhook-handler.ts`.
74
86
  */
75
- export declare function threadDataToEngineMessages(threadData: string | Record<string, unknown> | null | undefined): EngineMessage[];
87
+ export declare function threadDataToEngineMessages(threadData: string | Record<string, unknown> | null | undefined, options?: ThreadDataToEngineMessagesOptions): EngineMessage[];
76
88
  /**
77
89
  * The trailing window of what was actually said in a thread, for a request that
78
90
  * arrived carrying no history of its own. The client trims history against a
@@ -1,6 +1,7 @@
1
1
  import { formatChatErrorText, normalizeChatError, } from "../client/error-format.js";
2
2
  import { isCredentialGapCodeAgentEvent, normalizeCodeAgentTranscript, } from "../code-agents/transcript-normalizer.js";
3
3
  import { BUILDER_GATEWAY_INTERNAL_ERROR_CODE } from "./engine/error-detail.js";
4
+ import { stringifyToolUseInputForGateway } from "./engine/translate-anthropic.js";
4
5
  const INTERRUPTED_TOOL_RESULT = "Interrupted before this tool returned a result.";
5
6
  export const ASSISTANT_RUN_DURATION_METADATA_KEY = "agentNativeRunDurationMs";
6
7
  const MAX_STORED_ATTACHMENT_CHARS = 60_000;
@@ -653,17 +654,121 @@ export function normalizeThreadRepository(repo) {
653
654
  headId && seenIds.has(headId) ? headId : (previousId ?? null);
654
655
  return normalized;
655
656
  }
657
+ /**
658
+ * A replayed tool result is evidence, not the source of truth — the resumed
659
+ * turn can always re-read current state. Bound each one so restoring fidelity
660
+ * cannot itself overflow the context window, and say so in-band when it bites.
661
+ */
662
+ const MAX_REPLAYED_TOOL_RESULT_CHARS = 12_000;
663
+ /**
664
+ * Total budget for replayed tool payloads across the whole thread. Per-result
665
+ * capping alone is not a bound: a long run has hundreds of calls, so replaying
666
+ * every one of them would overflow the context window and break the very
667
+ * continuation this replay exists to serve. The newest turns keep their tool
668
+ * detail; older turns fall back to prose and say so.
669
+ */
670
+ const MAX_REPLAYED_TOOL_PAYLOAD_CHARS = 64_000;
671
+ const ELIDED_TOOL_DETAIL_NOTE = "[Tool calls from this turn were elided from replayed history to fit the context. Re-read the current state with tools if their detail matters.]";
672
+ function replayedToolResultContent(result) {
673
+ const body = typeof result === "string"
674
+ ? result
675
+ : result === undefined || result === null
676
+ ? ""
677
+ : (() => {
678
+ try {
679
+ return JSON.stringify(result) ?? "";
680
+ }
681
+ catch {
682
+ return String(result);
683
+ }
684
+ })();
685
+ if (body.length <= MAX_REPLAYED_TOOL_RESULT_CHARS)
686
+ return body;
687
+ const omitted = body.length - MAX_REPLAYED_TOOL_RESULT_CHARS;
688
+ return `${body.slice(0, MAX_REPLAYED_TOOL_RESULT_CHARS)}\n\n[Tool result truncated after ${MAX_REPLAYED_TOOL_RESULT_CHARS.toLocaleString()} characters; ${omitted.toLocaleString()} omitted from replayed history. Re-read the current state with tools if the exact content matters.]`;
689
+ }
690
+ /**
691
+ * Integration turns (Slack and friends) deliberately replay only what the
692
+ * participant saw plus a compact artifact ledger — never the raw tool results.
693
+ * `threadMessageTextForEngine` owns that policy, so a structured replay has to
694
+ * defer to it rather than reach past it into `content`.
695
+ */
696
+ function hasIntegrationReplayPolicy(message) {
697
+ const metadata = message?.metadata;
698
+ if (!metadata || typeof metadata !== "object")
699
+ return false;
700
+ return (metadata.integrationDelivery !== undefined ||
701
+ metadata.integrationDeliveryAttempted === true ||
702
+ Array.isArray(metadata.integrationArtifacts));
703
+ }
704
+ function replayableToolCalls(message) {
705
+ const content = Array.isArray(message?.content) ? message.content : [];
706
+ return content.filter((part) => part?.type === "tool-call" &&
707
+ typeof part.toolCallId === "string" &&
708
+ part.toolCallId.trim() &&
709
+ typeof part.toolName === "string" &&
710
+ part.toolName.trim());
711
+ }
712
+ /** What replaying this turn's tool calls with their results would actually cost. */
713
+ function replayedToolPayloadCost(message) {
714
+ let cost = 0;
715
+ for (const part of replayableToolCalls(message)) {
716
+ cost += stringifyToolUseInputForGateway(part.args ?? {}).length;
717
+ if (part.result !== undefined) {
718
+ cost += replayedToolResultContent(part.result).length;
719
+ }
720
+ }
721
+ return cost;
722
+ }
723
+ /**
724
+ * One persisted assistant turn spans many tool rounds. Replay it as the
725
+ * provider protocol wants it — one assistant message carrying every `tool-call`,
726
+ * then one user message carrying every matching `tool-result`. The exact
727
+ * round-by-round interleaving is not recoverable from thread_data and does not
728
+ * matter; what matters is that the calls and their outputs survive at all.
729
+ */
730
+ function assistantReplayContent(message, text) {
731
+ const assistant = [];
732
+ const results = [];
733
+ if (text.trim())
734
+ assistant.push({ type: "text", text });
735
+ const content = Array.isArray(message?.content) ? message.content : [];
736
+ for (const part of content) {
737
+ if (part?.type !== "tool-call")
738
+ continue;
739
+ const id = typeof part.toolCallId === "string" ? part.toolCallId.trim() : "";
740
+ const name = typeof part.toolName === "string" ? part.toolName.trim() : "";
741
+ if (!id || !name)
742
+ continue;
743
+ const input = part.args && typeof part.args === "object" && !Array.isArray(part.args)
744
+ ? part.args
745
+ : {};
746
+ assistant.push({ type: "tool-call", id, name, input });
747
+ if (part.result === undefined)
748
+ continue;
749
+ results.push({
750
+ type: "tool-result",
751
+ toolCallId: id,
752
+ toolName: name,
753
+ toolInput: stringifyToolUseInputForGateway(input),
754
+ content: replayedToolResultContent(part.result),
755
+ ...(part.isError === true ? { isError: true } : {}),
756
+ });
757
+ }
758
+ return { assistant, results };
759
+ }
656
760
  /**
657
761
  * Rebuild a flat `EngineMessage[]` from persisted thread_data (the
658
- * assistant-ui ExportedMessageRepository shape). Text-only — tool calls/results
659
- * are flattened to their text so a continuation run gets the conversation
660
- * prefix as plain context (Anthropic's prompt cache makes the resume cheap).
762
+ * assistant-ui ExportedMessageRepository shape). Each turn collapses to its
763
+ * text by default, which is all a recovery floor or a memory compaction needs.
764
+ * Callers resuming a run pass `includeToolCalls` to replay what the turn
765
+ * actually DID as well as what it said.
661
766
  *
662
767
  * Used to resume a background sub-agent in a fresh function invocation (the
663
768
  * server-side analog of the browser re-POSTing history for the main chat).
664
769
  * Originally inlined in `integrations/webhook-handler.ts`.
665
770
  */
666
- export function threadDataToEngineMessages(threadData) {
771
+ export function threadDataToEngineMessages(threadData, options = {}) {
667
772
  const messages = [];
668
773
  if (!threadData)
669
774
  return messages;
@@ -676,11 +781,60 @@ export function threadDataToEngineMessages(threadData) {
676
781
  }
677
782
  if (!Array.isArray(data?.messages))
678
783
  return messages;
679
- for (const entry of data.messages) {
784
+ const entries = data.messages;
785
+ const replaysTools = (m) => options.includeToolCalls === true &&
786
+ m?.role === "assistant" &&
787
+ !hasIntegrationReplayPolicy(m);
788
+ // Spend the tool-payload budget on the most recent turns: those are the ones
789
+ // a resumed run is about to build on. Decided up front so the walk below can
790
+ // stay in conversation order.
791
+ const toolPayloadAllowed = new Set();
792
+ if (options.includeToolCalls) {
793
+ let spent = 0;
794
+ for (let i = entries.length - 1; i >= 0; i--) {
795
+ const m = entries[i]?.message ?? entries[i];
796
+ if (!replaysTools(m))
797
+ continue;
798
+ const cost = replayedToolPayloadCost(m);
799
+ if (cost === 0)
800
+ continue;
801
+ if (spent + cost > MAX_REPLAYED_TOOL_PAYLOAD_CHARS)
802
+ continue;
803
+ spent += cost;
804
+ toolPayloadAllowed.add(i);
805
+ }
806
+ }
807
+ for (const [index, entry] of entries.entries()) {
680
808
  const m = entry?.message ?? entry;
681
809
  if (!m || (m.role !== "user" && m.role !== "assistant"))
682
810
  continue;
683
811
  const text = threadMessageTextForEngine(m);
812
+ if (replaysTools(m)) {
813
+ if (toolPayloadAllowed.has(index)) {
814
+ const { assistant, results } = assistantReplayContent(m, text);
815
+ if (assistant.length === 0)
816
+ continue;
817
+ messages.push({ role: "assistant", content: assistant });
818
+ if (results.length > 0) {
819
+ messages.push({ role: "user", content: results });
820
+ }
821
+ continue;
822
+ }
823
+ // Over budget, or nothing to replay. Prose still travels — but a turn
824
+ // whose tool detail was dropped must not read like a turn that never
825
+ // called a tool.
826
+ const elided = replayableToolCalls(m).length > 0;
827
+ const prose = elided
828
+ ? `${text.trim() ? `${text.trim()}\n\n` : ""}${ELIDED_TOOL_DETAIL_NOTE}`
829
+ : text;
830
+ if (!prose.trim())
831
+ continue;
832
+ messages.push({
833
+ role: "assistant",
834
+ content: [{ type: "text", text: prose }],
835
+ });
836
+ continue;
837
+ }
684
838
  if (!text.trim())
685
839
  continue;
686
840
  messages.push({ role: m.role, content: [{ type: "text", text }] });
@@ -2603,6 +2603,7 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
2603
2603
  // A restored tab can be reclassified as client-only after the thread list
2604
2604
  // loads. Once that happens, there is no server row to restore, so show the
2605
2605
  // empty composer instead of leaving the per-thread restore skeleton up.
2606
+ setThreadRestoreError(null);
2606
2607
  setIsRestoring(false);
2607
2608
  }, [isNewThread, threadId]);
2608
2609
  // Restore messages from server on mount (when threadId is set). The
@@ -66,6 +66,17 @@ const MAX_HISTORY_ATTACHMENT_CHARS = 60_000;
66
66
  // visibly (not silently) capped.
67
67
  const MAX_OUTBOUND_ATTACHMENT_CHARS = 200_000;
68
68
  const MAX_HISTORY_MESSAGES = 24;
69
+ // Every provider prompt cache matches a byte-identical PREFIX. A window that
70
+ // ends at the newest message and starts MAX_HISTORY_MESSAGES back moves its
71
+ // START by one message per turn, so from the first turn past the cap onward no
72
+ // cached prefix ever matches again and the whole conversation is re-billed at
73
+ // full write price on every turn — the cache breakpoints the engines place
74
+ // (system prefix, last tool, last user message) cannot save a prefix whose
75
+ // first bytes changed. Quantizing where the window starts holds those bytes
76
+ // identical for a stride of turns, so one turn in STRIDE pays the write and the
77
+ // rest read. The window then holds up to MAX + STRIDE - 1 messages; the char
78
+ // budgets below, not the message count, are what bound the payload.
79
+ const HISTORY_WINDOW_STRIDE = 8;
69
80
  const MAX_HISTORY_TOTAL_CHARS = 64_000;
70
81
  // Budget for everything actually said in the thread — every user ask and every
71
82
  // assistant conclusion. Separate from MAX_HISTORY_TOTAL_CHARS, which bounds the
@@ -788,7 +799,8 @@ function estimateHistoryMessageCost(message) {
788
799
  return Math.max(1, cost);
789
800
  }
790
801
  function limitPriorMessagesForRequest(messages) {
791
- const recent = messages.slice(-MAX_HISTORY_MESSAGES);
802
+ const overflow = Math.max(0, messages.length - MAX_HISTORY_MESSAGES);
803
+ const recent = messages.slice(Math.floor(overflow / HISTORY_WINDOW_STRIDE) * HISTORY_WINDOW_STRIDE);
792
804
  const kept = [];
793
805
  let words = 0;
794
806
  let payload = 0;
@@ -21,5 +21,5 @@ export function AppOpenActions({ name, href, target, rel, labels: labelOverrides
21
21
  if (!canOpen && !hasMenu) {
22
22
  return (_jsx(Button, { size: "sm", variant: "outline", disabled: true, children: labels.openApp }));
23
23
  }
24
- return (_jsxs("div", { className: cn("app-open-actions flex shrink-0 overflow-hidden rounded-md border border-border focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", className), children: [_jsx(Button, { asChild: primaryUsesHref, size: "sm", variant: "outline", className: "app-open-actions__primary h-7 rounded-none border-0 border-e border-border px-0 pe-2 ps-3 text-xs", disabled: !canOpen, onClick: onOpen, type: primaryUsesHref ? undefined : "button", "aria-label": !primaryUsesHref ? `Open ${name}` : undefined, children: primaryUsesHref ? (_jsx("a", { href: href ?? undefined, target: target, rel: rel, children: labels.openApp })) : (_jsx("span", { children: labels.openApp })) }), hasMenu ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", size: "sm", variant: "outline", className: "app-open-actions__menu size-7 rounded-none border-0 p-0 text-xs", "aria-label": `Open options for ${name}`, children: _jsx(IconChevronDown, { size: 15 }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: APP_ACTION_MENU_CONTENT_CLASS, children: [onAddApp ? (_jsxs(DropdownMenuItem, { onSelect: onAddApp, children: [_jsx(IconPlus, { size: 14, "aria-hidden": "true" }), labels.addApp] })) : null, showInlineOption && href && onOpenInline ? (_jsxs(DropdownMenuItem, { onSelect: onOpenInline, children: [_jsx(IconAppWindow, { size: 14, "aria-hidden": "true" }), labels.openInline] })) : null, canOfferNewTab && href ? (_jsx(DropdownMenuItem, { asChild: true, children: _jsxs("a", { href: href ?? undefined, target: "_blank", rel: "noreferrer", children: [_jsx(IconExternalLink, { size: 14, "aria-hidden": "true" }), labels.openInNewTab] }) })) : null, menuItems?.map((item) => (_jsxs(DropdownMenuItem, { onSelect: item.onSelect, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "shrink-0", children: item.icon })) : null, item.label] }, item.id)))] })] })) : null] }));
24
+ return (_jsxs("div", { className: cn("app-open-actions flex shrink-0 overflow-hidden rounded-md border border-border focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", className), children: [_jsx(Button, { asChild: primaryUsesHref, size: "sm", variant: "outline", className: "app-open-actions__primary h-7 rounded-none border-0 border-e border-border px-0 pe-2 ps-3 text-xs", disabled: !canOpen, onClick: onOpen, type: primaryUsesHref ? undefined : "button", "aria-label": !primaryUsesHref ? `Open ${name}` : undefined, children: primaryUsesHref ? (_jsx("a", { href: href ?? undefined, target: target, rel: rel, children: labels.openApp })) : (_jsx("span", { children: labels.openApp })) }), hasMenu ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", size: "sm", variant: "outline", className: "app-open-actions__menu size-7 rounded-none border-0 p-0 text-xs", "aria-label": `Open options for ${name}`, children: _jsx(IconChevronDown, { size: 15 }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: cn(APP_ACTION_MENU_CONTENT_CLASS, "min-w-max"), children: [onAddApp ? (_jsxs(DropdownMenuItem, { onSelect: onAddApp, children: [_jsx(IconPlus, { size: 14, "aria-hidden": "true" }), labels.addApp] })) : null, showInlineOption && href && onOpenInline ? (_jsxs(DropdownMenuItem, { onSelect: onOpenInline, children: [_jsx(IconAppWindow, { size: 14, "aria-hidden": "true" }), labels.openInline] })) : null, canOfferNewTab && href ? (_jsx(DropdownMenuItem, { asChild: true, children: _jsxs("a", { href: href ?? undefined, target: "_blank", rel: "noreferrer", children: [_jsx(IconExternalLink, { size: 14, "aria-hidden": "true" }), labels.openInNewTab] }) })) : null, menuItems?.map((item) => (_jsxs(DropdownMenuItem, { onSelect: item.onSelect, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "shrink-0", children: item.icon })) : null, item.label] }, item.id)))] })] })) : null] }));
25
25
  }
@@ -12,8 +12,8 @@ const DEFAULT_COPY = {
12
12
  createApp: "Create app",
13
13
  retry: "Retry",
14
14
  dismiss: "Dismiss",
15
- unpinApp: "Unpin {name}",
16
- pinApp: "Pin {name}",
15
+ unpinApp: "Unpin this app",
16
+ pinApp: "Pin this app",
17
17
  removePinned: "Remove from pinned apps",
18
18
  pinTop: "Pin app to the top",
19
19
  moveUp: "Move up",
@@ -55,6 +55,11 @@ export function McpAppRenderer({ app, className }) {
55
55
  setReady(true);
56
56
  setError(null);
57
57
  }, []);
58
+ const handleIframeError = useCallback(() => {
59
+ readyRef.current = false;
60
+ setReady(false);
61
+ setError("MCP App iframe failed to load.");
62
+ }, []);
58
63
  const applyHeight = useCallback((desiredHeight) => {
59
64
  if (typeof desiredHeight === "number" &&
60
65
  Number.isFinite(desiredHeight) &&
@@ -228,9 +233,9 @@ export function McpAppRenderer({ app, className }) {
228
233
  // new-but-equal `app` object reference does not tear down a live bridge.
229
234
  }, [applyHeight, markReady, srcDoc]);
230
235
  if (!resourceHtml) {
231
- return (_jsxs("div", { className: cn("agent-mcp-app agent-mcp-app--error", className), children: [_jsx(IconAlertTriangle, { size: 15 }), _jsx("span", { children: "MCP App resource was not available." })] }));
236
+ return (_jsxs("div", { className: cn("agent-mcp-app agent-mcp-app--error", className), children: [_jsx(IconAlertTriangle, { size: 15 }), _jsx("span", { children: "MCP App resource was not available." }), externalOpenUrl && (_jsx("button", { type: "button", className: "agent-mcp-app__open", onClick: () => window.open(externalOpenUrl, "_blank", "noopener,noreferrer"), children: "Open in new tab" }))] }));
232
237
  }
233
- return (_jsxs("div", { className: cn("agent-mcp-app", uiMeta.prefersBorder === false && "agent-mcp-app--flush", className), children: [!ready && !error && (_jsxs("div", { className: "agent-mcp-app__loading", children: [_jsx(IconLoader2, { size: 14, className: "agent-conversation-spin" }), _jsx("span", { children: "Loading MCP App" })] })), error && (_jsxs("div", { className: "agent-mcp-app__error", children: [_jsx(IconAlertTriangle, { size: 15 }), _jsx("span", { children: error }), externalOpenUrl && (_jsx("button", { type: "button", className: "agent-mcp-app__open", onClick: () => window.open(externalOpenUrl, "_blank", "noopener,noreferrer"), children: "Open in new tab" }))] })), _jsx("iframe", { ref: iframeRef, title: app.tool?.title ?? app.originalToolName, srcDoc: srcDoc, sandbox: SANDBOX_FLAGS, allow: buildAllowAttribute(supportedPermissions), style: { height } })] }));
238
+ return (_jsxs("div", { className: cn("agent-mcp-app", uiMeta.prefersBorder === false && "agent-mcp-app--flush", className), children: [!ready && !error && (_jsxs("div", { className: "agent-mcp-app__loading", children: [_jsx(IconLoader2, { size: 14, className: "agent-conversation-spin" }), _jsx("span", { children: "Loading MCP App" })] })), error && (_jsx("div", { className: "agent-mcp-app__error", role: "alert", children: _jsxs("div", { className: "agent-mcp-app__error-box", children: [_jsx(IconAlertTriangle, { size: 15 }), _jsx("span", { children: error }), externalOpenUrl && (_jsx("button", { type: "button", className: "agent-mcp-app__open", onClick: () => window.open(externalOpenUrl, "_blank", "noopener,noreferrer"), children: "Open in new tab" }))] }) })), _jsx("iframe", { ref: iframeRef, title: app.tool?.title ?? app.originalToolName, srcDoc: srcDoc, sandbox: SANDBOX_FLAGS, allow: buildAllowAttribute(supportedPermissions), style: { height }, onError: handleIframeError })] }));
234
239
  }
235
240
  function messageTextFromMcpUiMessage(params) {
236
241
  return textPartsFromMcpContent(params.content).join("\n\n").trim();
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- error: string;
17
16
  ok?: undefined;
17
+ error: string;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -47,6 +47,8 @@ export function embedApp(options = {}) {
47
47
  iframe { display: block; width: 100%; height: var(--agent-native-viewport-height); border: 0; background: Canvas; }
48
48
  .message { display: grid; place-items: center; min-height: var(--agent-native-viewport-height); padding: 18px; color: color-mix(in srgb, CanvasText 62%, Canvas); font-size: 13px; line-height: 1.45; text-align: center; }
49
49
  .fallback { display: grid; align-content: center; justify-items: center; gap: 12px; min-height: var(--agent-native-viewport-height); padding: 24px; background: Canvas; color: CanvasText; text-align: center; }
50
+ .fallback-overlay { position: absolute; inset: 0; z-index: 2; display: grid; align-items: start; justify-items: end; padding: 12px; pointer-events: none; }
51
+ .fallback-overlay .fallback { width: min(100%, 560px); min-height: 0; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 9px; background: color-mix(in srgb, Canvas 96%, transparent); box-shadow: 0 8px 24px color-mix(in srgb, CanvasText 16%, transparent); pointer-events: auto; }
50
52
  .fallback-title { max-width: 440px; font-size: 14px; font-weight: 700; }
51
53
  .fallback-copy { max-width: 520px; color: color-mix(in srgb, CanvasText 64%, Canvas); font-size: 13px; line-height: 1.45; }
52
54
  .fallback-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
@@ -1073,14 +1075,64 @@ export function embedApp(options = {}) {
1073
1075
  } catch {}
1074
1076
  }
1075
1077
 
1078
+ function clearFallbackOverlay() {
1079
+ stage.querySelector("[data-fallback-overlay]")?.remove();
1080
+ }
1081
+
1082
+ function renderFallbackOverlay({ title, copyHtml, retryLabel, onRetry }) {
1083
+ if (!appFrame || appFrame.parentElement !== stage) return false;
1084
+ clearFallbackOverlay();
1085
+ const overlay = document.createElement("div");
1086
+ overlay.className = "fallback-overlay";
1087
+ overlay.dataset.fallbackOverlay = "1";
1088
+ overlay.innerHTML =
1089
+ '<div class="fallback">' +
1090
+ '<div class="fallback-title">' + esc(title) + '</div>' +
1091
+ '<div class="fallback-copy">' + copyHtml + '</div>' +
1092
+ '<div class="fallback-actions">' +
1093
+ '<button type="button" class="primary" data-fallback-open>Open in new tab</button>' +
1094
+ '<button type="button" data-fallback-retry>' + esc(retryLabel) + '</button>' +
1095
+ '</div>' +
1096
+ (openUrl ? '<a class="fallback-url" href="' + esc(openUrl) + '" target="_blank" rel="noreferrer">' + esc(openUrl) + '</a>' : '') +
1097
+ '</div>';
1098
+ stage.appendChild(overlay);
1099
+
1100
+ const fallbackOpen = overlay.querySelector("[data-fallback-open]");
1101
+ const fallbackRetry = overlay.querySelector("[data-fallback-retry]");
1102
+ if (fallbackOpen) {
1103
+ fallbackOpen.disabled = !openUrl;
1104
+ fallbackOpen.onclick = () => {
1105
+ if (openUrl) void openFallbackExternal();
1106
+ };
1107
+ }
1108
+ if (fallbackRetry) {
1109
+ fallbackRetry.disabled = typeof onRetry !== "function";
1110
+ fallbackRetry.onclick = () => {
1111
+ if (typeof onRetry === "function") void onRetry();
1112
+ };
1113
+ }
1114
+ return true;
1115
+ }
1116
+
1076
1117
  function renderFrameFallback() {
1077
1118
  clearFrameReadyTimer();
1078
1119
  clearFrameLoadTimer();
1079
- appFrame = null;
1120
+ appFrameReady = false;
1080
1121
  const fallbackCopy = openUrl
1081
1122
  ? "This chat host did not allow the embedded app frame to load inline. You can still open the same app route through the host or use the URL below."
1082
1123
  : "This chat host did not allow the embedded app frame to load inline.";
1083
1124
  reportEmbedError("frame-fallback", fallbackCopy);
1125
+ if (renderFallbackOverlay({
1126
+ title: "Open this app in its own tab",
1127
+ copyHtml: esc(fallbackCopy),
1128
+ retryLabel: "Try inline again",
1129
+ onRetry: lastFrameSrc
1130
+ ? () => renderFrame(lastFrameSrc)
1131
+ : undefined
1132
+ })) {
1133
+ return;
1134
+ }
1135
+ appFrame = null;
1084
1136
  stage.innerHTML =
1085
1137
  '<div class="fallback">' +
1086
1138
  '<div class="fallback-title">Open this app in its own tab</div>' +
@@ -1110,7 +1162,7 @@ export function embedApp(options = {}) {
1110
1162
  function renderAppLaunchError(message) {
1111
1163
  clearFrameReadyTimer();
1112
1164
  clearFrameLoadTimer();
1113
- appFrame = null;
1165
+ appFrameReady = false;
1114
1166
  const fallbackCopy = openUrl
1115
1167
  ? "The inline MCP app could not load in this chat host. You can open the same app route in a new tab or retry the inline load."
1116
1168
  : "The inline MCP app could not load in this chat host.";
@@ -1118,6 +1170,18 @@ export function embedApp(options = {}) {
1118
1170
  const copyHtml = message
1119
1171
  ? '<div>' + esc(message) + '</div><div>' + esc(fallbackCopy) + '</div>'
1120
1172
  : esc(fallbackCopy);
1173
+ if (renderFallbackOverlay({
1174
+ title: "App did not load",
1175
+ copyHtml,
1176
+ retryLabel: "Retry",
1177
+ onRetry: () => {
1178
+ startedFor = "";
1179
+ void launchEmbed();
1180
+ }
1181
+ })) {
1182
+ return;
1183
+ }
1184
+ appFrame = null;
1121
1185
  stage.innerHTML =
1122
1186
  '<div class="fallback">' +
1123
1187
  '<div class="fallback-title">App did not load</div>' +
@@ -1178,6 +1242,10 @@ export function embedApp(options = {}) {
1178
1242
  sendFrameReadyMessages(frame);
1179
1243
  startFrameReadyTimer(frame);
1180
1244
  });
1245
+ frame.addEventListener("error", () => {
1246
+ if (appFrame !== frame) return;
1247
+ renderFrameFallback();
1248
+ });
1181
1249
  stage.replaceChildren(frame);
1182
1250
  notifyHostHeight();
1183
1251
  appFrameLoadTimer = setTimeout(() => {
@@ -1475,6 +1543,7 @@ export function embedApp(options = {}) {
1475
1543
  const data = event.data.data || {};
1476
1544
  if (message.type === "agentNative.embeddedAppReady") {
1477
1545
  appFrameReady = true;
1546
+ clearFallbackOverlay();
1478
1547
  embedSessionRefreshAttempts = 0;
1479
1548
  clearFrameLoadTimer();
1480
1549
  clearFrameReadyTimer();
@@ -28,6 +28,6 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
28
28
  } | {
29
29
  count?: undefined;
30
30
  updated?: undefined;
31
- ok: boolean;
32
31
  error?: undefined;
32
+ ok: boolean;
33
33
  }>>;