@agent-native/core 0.81.2 → 0.82.0
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/frame.ts +55 -0
- package/corpus/core/src/file-upload/builder.ts +199 -37
- package/corpus/core/src/file-upload/index.ts +2 -0
- package/corpus/core/src/file-upload/types.ts +38 -0
- package/corpus/core/src/mcp/build-server.ts +30 -4
- package/corpus/core/src/mcp/embed-app.ts +33 -18
- package/corpus/templates/clips/actions/create-recording.ts +44 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
- package/corpus/templates/clips/app/routes/record.tsx +12 -1
- package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
- package/corpus/templates/clips/shared/recording-core.ts +5 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
- package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
- package/corpus/templates/design/app/pages/DesignEditor.tsx +557 -211
- package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +3 -1
- package/corpus/templates/design/e2e/helpers.ts +4 -1
- package/corpus/templates/design/playwright.config.ts +4 -1
- package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
- package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
- package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
- package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
- package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
- package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
- package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
- package/corpus/templates/plan/actions/visual-answer.ts +1 -1
- package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
- package/corpus/templates/plan/app/entry.client.tsx +10 -0
- package/corpus/templates/plan/app/global.css +28 -0
- package/corpus/templates/plan/shared/plan-content.ts +10 -6
- package/dist/client/frame.d.ts.map +1 -1
- package/dist/client/frame.js +59 -0
- package/dist/client/frame.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +137 -25
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.d.ts.map +1 -1
- package/dist/file-upload/index.js.map +1 -1
- package/dist/file-upload/types.d.ts +26 -0
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +31 -4
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +33 -18
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.82.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- fe9fd99: Add optional `resumable` capability to `FileUploadProvider` for streaming uploads. Providers that implement `startSession`, `relayChunk`, and `completeSession` can receive video chunks during recording instead of waiting for a fully assembled file after stop. The Builder.io provider implements this via the GCS resumable upload protocol. Also exports `ResumableUploadSession` and `ResumableChunkResult` types.
|
|
8
|
+
|
|
9
|
+
## 0.81.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3807702: Fix MCP App embed rendering in ChatGPT/Codex hosts:
|
|
14
|
+
- Stop the `openai:set_globals` storm that left embeds stuck blanking by
|
|
15
|
+
guarding the bridge sync on a signature that ignores host `maxHeight`, and
|
|
16
|
+
by not re-blanking once the app frame has launched.
|
|
17
|
+
- Size embeds to their content: the embedded app reports its real
|
|
18
|
+
`scrollHeight` via `agentNative.contentHeight` and the shell sizes the iframe
|
|
19
|
+
to that (plus chrome) instead of the host max, so plans no longer render far
|
|
20
|
+
too tall or too short.
|
|
21
|
+
- Suppress empty embeds: when a tool whose descriptor declares an embed widget
|
|
22
|
+
produces no embeddable content, the result is marked `isError` so the host
|
|
23
|
+
shows the text result without an empty widget box. This also keeps read-only
|
|
24
|
+
and comment-mutation tools from rendering an embed for results that produce
|
|
25
|
+
no plan surface.
|
|
26
|
+
- Bump the embed shell resource version so hosts refetch the updated shell.
|
|
27
|
+
|
|
3
28
|
## 0.81.2
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.0",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -83,6 +83,60 @@ export function isTrustedFrameMessage(event: MessageEvent): boolean {
|
|
|
83
83
|
return event.source === window.parent || event.source === window;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Content-height reporting (for MCP App embeds)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
//
|
|
90
|
+
// The embed shell renders this app in a cross-origin iframe and can't measure
|
|
91
|
+
// its content, so we post the real content height for the shell to size to.
|
|
92
|
+
// Only shrinks when the document flows to its natural height — embed layouts
|
|
93
|
+
// must not force `100vh`/`h-screen` on the root, or scrollHeight just equals
|
|
94
|
+
// the iframe height.
|
|
95
|
+
let _contentHeightReportingStarted = false;
|
|
96
|
+
let _lastReportedContentHeight = 0;
|
|
97
|
+
|
|
98
|
+
function measureContentHeight(): number {
|
|
99
|
+
if (typeof document === "undefined") return 0;
|
|
100
|
+
const doc = document.documentElement;
|
|
101
|
+
const body = document.body;
|
|
102
|
+
return Math.ceil(
|
|
103
|
+
Math.max(doc ? doc.scrollHeight : 0, body ? body.scrollHeight : 0),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reportContentHeight(): void {
|
|
108
|
+
if (typeof window === "undefined" || window.parent === window) return;
|
|
109
|
+
const height = measureContentHeight();
|
|
110
|
+
if (!height || Math.abs(height - _lastReportedContentHeight) < 2) return;
|
|
111
|
+
_lastReportedContentHeight = height;
|
|
112
|
+
sendToFrame("agentNative.contentHeight", { height });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function startContentHeightReporting(): void {
|
|
116
|
+
if (_contentHeightReportingStarted || typeof window === "undefined") return;
|
|
117
|
+
if (window.parent === window) return;
|
|
118
|
+
_contentHeightReportingStarted = true;
|
|
119
|
+
let scheduled = false;
|
|
120
|
+
const schedule = () => {
|
|
121
|
+
if (scheduled) return;
|
|
122
|
+
scheduled = true;
|
|
123
|
+
window.requestAnimationFrame(() => {
|
|
124
|
+
scheduled = false;
|
|
125
|
+
reportContentHeight();
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
schedule();
|
|
129
|
+
for (const delay of [100, 300, 800, 1500]) window.setTimeout(schedule, delay);
|
|
130
|
+
try {
|
|
131
|
+
const observer = new ResizeObserver(schedule);
|
|
132
|
+
if (document.documentElement) observer.observe(document.documentElement);
|
|
133
|
+
if (document.body) observer.observe(document.body);
|
|
134
|
+
} catch {
|
|
135
|
+
window.addEventListener("resize", schedule, { passive: true });
|
|
136
|
+
}
|
|
137
|
+
window.addEventListener("load", schedule, { passive: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
86
140
|
// Listen for frame origin message and cache it.
|
|
87
141
|
// Only accept from the direct parent frame, and only set once.
|
|
88
142
|
if (typeof window !== "undefined") {
|
|
@@ -102,6 +156,7 @@ if (typeof window !== "undefined") {
|
|
|
102
156
|
{ type: "agentNative.embeddedAppReady" },
|
|
103
157
|
getFramePostMessageTargetOrigin() ?? window.location.origin,
|
|
104
158
|
);
|
|
159
|
+
startContentHeightReporting();
|
|
105
160
|
}
|
|
106
161
|
});
|
|
107
162
|
}
|
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
FileUploadProvider,
|
|
3
3
|
FileUploadInput,
|
|
4
4
|
FileUploadResult,
|
|
5
|
+
ResumableUploadSession,
|
|
6
|
+
ResumableChunkResult,
|
|
5
7
|
} from "./types.js";
|
|
6
8
|
|
|
7
9
|
const DEFAULT_BUILDER_APP_HOST = "https://builder.io";
|
|
@@ -46,8 +48,6 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
46
48
|
bareMimeType: string,
|
|
47
49
|
bytes: Uint8Array,
|
|
48
50
|
): Promise<FileUploadResult> {
|
|
49
|
-
const host = builderUploadHost();
|
|
50
|
-
const authHeader = { Authorization: `Bearer ${privateKey}` };
|
|
51
51
|
const name = input.filename ?? "upload";
|
|
52
52
|
const mb = (bytes.byteLength / (1024 * 1024)).toFixed(1);
|
|
53
53
|
|
|
@@ -57,32 +57,12 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
57
57
|
|
|
58
58
|
// Step 1 — request a signed URL.
|
|
59
59
|
console.log(`[builder-upload] step 1: requesting signed URL`);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
body: JSON.stringify({
|
|
66
|
-
fileName: name,
|
|
67
|
-
contentType: bareMimeType,
|
|
68
|
-
size: bytes.byteLength,
|
|
69
|
-
}),
|
|
70
|
-
},
|
|
60
|
+
const { uploadUrl, assetId, requiredHeaders } = await requestBuilderSignedUrl(
|
|
61
|
+
privateKey,
|
|
62
|
+
name,
|
|
63
|
+
bareMimeType,
|
|
64
|
+
bytes.byteLength,
|
|
71
65
|
);
|
|
72
|
-
await assertOk(step1Res, "Builder.io signed-URL request failed");
|
|
73
|
-
|
|
74
|
-
const step1Json = (await step1Res.json()) as {
|
|
75
|
-
uploadUrl?: string;
|
|
76
|
-
assetId?: string;
|
|
77
|
-
expiresAt?: string;
|
|
78
|
-
requiredHeaders?: Record<string, string>;
|
|
79
|
-
};
|
|
80
|
-
const { uploadUrl, assetId, requiredHeaders } = step1Json;
|
|
81
|
-
if (!uploadUrl || !assetId || !requiredHeaders) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
`Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(step1Json))}`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
66
|
console.log(`[builder-upload] step 1 ok: assetId=${assetId}`);
|
|
87
67
|
|
|
88
68
|
// Step 2 — PUT bytes directly to GCS. Only requiredHeaders; no Authorization
|
|
@@ -102,21 +82,80 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
102
82
|
console.log(
|
|
103
83
|
`[builder-upload] step 3: registering asset - ${assetId}, ${input.filename}`,
|
|
104
84
|
);
|
|
105
|
-
const
|
|
85
|
+
const { url, id } = await completeBuilderUpload(
|
|
86
|
+
privateKey,
|
|
87
|
+
assetId,
|
|
88
|
+
input.filename,
|
|
89
|
+
);
|
|
90
|
+
console.log(`[builder-upload] done [${assetId}]: ${url}`);
|
|
91
|
+
return { url, id, provider: "builder" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function requestBuilderSignedUrl(
|
|
95
|
+
privateKey: string,
|
|
96
|
+
filename: string,
|
|
97
|
+
mimeType: string,
|
|
98
|
+
size: number,
|
|
99
|
+
resumable = false,
|
|
100
|
+
): Promise<{
|
|
101
|
+
uploadUrl: string;
|
|
102
|
+
assetId: string;
|
|
103
|
+
requiredHeaders: Record<string, string>;
|
|
104
|
+
}> {
|
|
105
|
+
const host = builderUploadHost();
|
|
106
|
+
const url = new URL("/api/v1/upload/signed-url", host);
|
|
107
|
+
const res = await fetchWithTimeout(url.toString(), {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${privateKey}`,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
fileName: filename,
|
|
115
|
+
contentType: mimeType,
|
|
116
|
+
size,
|
|
117
|
+
resumable,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
await assertOk(res, "Builder.io signed-URL request failed");
|
|
121
|
+
const json = (await res.json()) as {
|
|
122
|
+
uploadUrl?: string;
|
|
123
|
+
assetId?: string;
|
|
124
|
+
requiredHeaders?: Record<string, string>;
|
|
125
|
+
};
|
|
126
|
+
if (!json.uploadUrl || !json.assetId || !json.requiredHeaders) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(json))}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
uploadUrl: json.uploadUrl,
|
|
133
|
+
assetId: json.assetId,
|
|
134
|
+
requiredHeaders: json.requiredHeaders,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function completeBuilderUpload(
|
|
139
|
+
privateKey: string,
|
|
140
|
+
assetId: string,
|
|
141
|
+
filename: string | undefined,
|
|
142
|
+
): Promise<{ url: string; id?: string }> {
|
|
143
|
+
const host = builderUploadHost();
|
|
144
|
+
const res = await fetchWithTimeout(
|
|
106
145
|
new URL("/api/v1/upload/complete", host).toString(),
|
|
107
146
|
{
|
|
108
147
|
method: "POST",
|
|
109
|
-
headers: {
|
|
110
|
-
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${privateKey}`,
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ assetId, name: filename }),
|
|
111
153
|
},
|
|
112
154
|
);
|
|
113
|
-
await assertOk(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.log(`[builder-upload] done [${assetId}]: ${url}`);
|
|
119
|
-
return { url, id, provider: "builder" };
|
|
155
|
+
await assertOk(res, "Builder.io upload complete failed");
|
|
156
|
+
const json = (await res.json()) as { url?: string; id?: string };
|
|
157
|
+
if (!json.url) throw new Error("Builder.io upload/complete returned no URL");
|
|
158
|
+
return { url: json.url, id: json.id };
|
|
120
159
|
}
|
|
121
160
|
|
|
122
161
|
// Retry transient 5xx once with backoff. Builder.io's upload service
|
|
@@ -221,4 +260,127 @@ export const builderFileUploadProvider: FileUploadProvider = {
|
|
|
221
260
|
console.log(`[builder-upload] done: ${json.url}`);
|
|
222
261
|
return { url: json.url, id: json.id, provider: "builder" };
|
|
223
262
|
},
|
|
263
|
+
|
|
264
|
+
resumable: {
|
|
265
|
+
async startSession(filename, mimeType, maxBytes) {
|
|
266
|
+
const { resolveBuilderPrivateKey } =
|
|
267
|
+
await import("../server/credential-provider.js");
|
|
268
|
+
const privateKey = await resolveBuilderPrivateKey();
|
|
269
|
+
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
|
|
270
|
+
|
|
271
|
+
console.log(
|
|
272
|
+
`[builder-resumable] starting session: ${filename} ${mimeType} ${maxBytes} bytes`,
|
|
273
|
+
);
|
|
274
|
+
const { uploadUrl, assetId, requiredHeaders } =
|
|
275
|
+
await requestBuilderSignedUrl(
|
|
276
|
+
privateKey,
|
|
277
|
+
filename,
|
|
278
|
+
mimeType,
|
|
279
|
+
maxBytes,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
console.log(`[builder-resumable] session step 1 ok: assetId=${assetId}`);
|
|
283
|
+
|
|
284
|
+
const initHeaders: Record<string, string> = {
|
|
285
|
+
"Content-Type": mimeType,
|
|
286
|
+
"x-goog-resumable": "start",
|
|
287
|
+
};
|
|
288
|
+
const contentLengthRange =
|
|
289
|
+
requiredHeaders?.["x-goog-content-length-range"];
|
|
290
|
+
if (contentLengthRange)
|
|
291
|
+
initHeaders["x-goog-content-length-range"] = contentLengthRange;
|
|
292
|
+
|
|
293
|
+
console.log(`[builder-resumable] session step 2: initiating GCS session`);
|
|
294
|
+
const initRes = await fetchWithTimeout(uploadUrl, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: initHeaders,
|
|
297
|
+
body: new Uint8Array(0),
|
|
298
|
+
});
|
|
299
|
+
if (!initRes.ok) {
|
|
300
|
+
const body = await initRes.text().catch(() => "");
|
|
301
|
+
throw new Error(
|
|
302
|
+
`GCS resumable session initiation failed (${initRes.status}): ${body}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const sessionUri = initRes.headers.get("location");
|
|
306
|
+
if (!sessionUri)
|
|
307
|
+
throw new Error(
|
|
308
|
+
"GCS did not return a Location header for the resumable session",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
console.log(`[builder-resumable] session ready: assetId=${assetId}`);
|
|
312
|
+
return {
|
|
313
|
+
sessionId: sessionUri,
|
|
314
|
+
meta: { assetId, filename, mimeType },
|
|
315
|
+
} satisfies ResumableUploadSession;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async relayChunk(session, contentRange, bytes, options) {
|
|
319
|
+
const sessionUri = session.sessionId;
|
|
320
|
+
const MAX_ATTEMPTS = 4;
|
|
321
|
+
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
|
|
322
|
+
const delayMs = (attempt: number) =>
|
|
323
|
+
Math.min(2000, 300 * 2 ** (attempt - 1));
|
|
324
|
+
|
|
325
|
+
let lastError: unknown = null;
|
|
326
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
327
|
+
try {
|
|
328
|
+
const headers: Record<string, string> = {
|
|
329
|
+
"Content-Range": contentRange,
|
|
330
|
+
};
|
|
331
|
+
if (options?.mimeType) headers["Content-Type"] = options.mimeType;
|
|
332
|
+
const res = await fetch(sessionUri, {
|
|
333
|
+
method: "PUT",
|
|
334
|
+
headers,
|
|
335
|
+
body: bytes as unknown as BodyInit,
|
|
336
|
+
});
|
|
337
|
+
if (res.status === 308 || res.ok)
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
status: res.status,
|
|
341
|
+
} satisfies ResumableChunkResult;
|
|
342
|
+
if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {
|
|
343
|
+
await res.text().catch(() => "");
|
|
344
|
+
console.warn(
|
|
345
|
+
`[builder-resumable] transient ${res.status} on attempt ${attempt}, retrying`,
|
|
346
|
+
);
|
|
347
|
+
await new Promise((r) => setTimeout(r, delayMs(attempt)));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: res.status,
|
|
353
|
+
} satisfies ResumableChunkResult;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
lastError = err;
|
|
356
|
+
if (attempt >= MAX_ATTEMPTS) break;
|
|
357
|
+
console.warn(
|
|
358
|
+
`[builder-resumable] network error on attempt ${attempt}:`,
|
|
359
|
+
err instanceof Error ? err.message : String(err),
|
|
360
|
+
);
|
|
361
|
+
await new Promise((r) => setTimeout(r, delayMs(attempt)));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw lastError instanceof Error
|
|
365
|
+
? lastError
|
|
366
|
+
: new Error("GCS PUT failed after retries");
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async completeSession(session, filename) {
|
|
370
|
+
const { resolveBuilderPrivateKey } =
|
|
371
|
+
await import("../server/credential-provider.js");
|
|
372
|
+
const privateKey = await resolveBuilderPrivateKey();
|
|
373
|
+
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
|
|
374
|
+
|
|
375
|
+
const assetId = session.meta.assetId as string;
|
|
376
|
+
console.log(`[builder-resumable] completing upload: assetId=${assetId}`);
|
|
377
|
+
const { url } = await completeBuilderUpload(
|
|
378
|
+
privateKey,
|
|
379
|
+
assetId,
|
|
380
|
+
filename,
|
|
381
|
+
);
|
|
382
|
+
console.log(`[builder-resumable] upload complete: ${url}`);
|
|
383
|
+
return url;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
224
386
|
};
|
|
@@ -27,6 +27,22 @@ export interface FileUploadResult {
|
|
|
27
27
|
provider: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
|
|
31
|
+
* `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
|
|
32
|
+
* `meta` holds any provider state needed for subsequent relay and complete calls. */
|
|
33
|
+
export interface ResumableUploadSession {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
meta: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResumableChunkResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
status: number;
|
|
41
|
+
/** Providers that need per-chunk state (e.g. S3 ETags) return updated meta
|
|
42
|
+
* here; the chunk route merges it back into the stored session. */
|
|
43
|
+
updatedMeta?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
export interface FileUploadProvider {
|
|
31
47
|
/** Unique id, e.g. "builder", "s3". */
|
|
32
48
|
id: string;
|
|
@@ -41,4 +57,26 @@ export interface FileUploadProvider {
|
|
|
41
57
|
isConfiguredForRequest?: () => Promise<boolean>;
|
|
42
58
|
/** Upload a file and return a URL. Throw on failure. */
|
|
43
59
|
upload: (input: FileUploadInput) => Promise<FileUploadResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Optional resumable/streaming upload capability.
|
|
62
|
+
* When present, create-recording will initialise a session and stream chunks
|
|
63
|
+
* during recording instead of assembling the full blob after stop().
|
|
64
|
+
*/
|
|
65
|
+
resumable?: {
|
|
66
|
+
startSession(
|
|
67
|
+
filename: string,
|
|
68
|
+
mimeType: string,
|
|
69
|
+
maxBytes: number,
|
|
70
|
+
): Promise<ResumableUploadSession>;
|
|
71
|
+
relayChunk(
|
|
72
|
+
session: ResumableUploadSession,
|
|
73
|
+
contentRange: string,
|
|
74
|
+
bytes: Uint8Array,
|
|
75
|
+
options?: { mimeType?: string },
|
|
76
|
+
): Promise<ResumableChunkResult>;
|
|
77
|
+
completeSession(
|
|
78
|
+
session: ResumableUploadSession,
|
|
79
|
+
filename: string,
|
|
80
|
+
): Promise<string>;
|
|
81
|
+
};
|
|
44
82
|
}
|
|
@@ -448,6 +448,18 @@ function purgeEmbedStartUrls(value: unknown, depth = 0): unknown {
|
|
|
448
448
|
return value;
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
+
// True when a tool result carries SOME content. An errored/no-plan result comes
|
|
452
|
+
// back empty (`{}` / null) and would render an empty embed box. We gate on "has
|
|
453
|
+
// content", not "has a URL" — valid embeds often carry data but no URL (the
|
|
454
|
+
// shell mints the embed-start itself).
|
|
455
|
+
function mcpResultHasContent(result: unknown): boolean {
|
|
456
|
+
if (result == null) return false;
|
|
457
|
+
if (typeof result === "string") return result.trim().length > 0;
|
|
458
|
+
if (Array.isArray(result)) return result.length > 0;
|
|
459
|
+
if (typeof result === "object") return Object.keys(result).length > 0;
|
|
460
|
+
return Boolean(result);
|
|
461
|
+
}
|
|
462
|
+
|
|
451
463
|
function mcpAppEmbedOpenLinkMeta(
|
|
452
464
|
result: unknown,
|
|
453
465
|
resource: ResolvedMcpAppResource,
|
|
@@ -750,7 +762,7 @@ function safeUiSegment(value: string | undefined, fallback: string): string {
|
|
|
750
762
|
|
|
751
763
|
// ChatGPT and Claude cache MCP App resource HTML by `ui://` URI. Bump this
|
|
752
764
|
// when the shared shell changes in a way that must invalidate host caches.
|
|
753
|
-
const MCP_APP_RESOURCE_SHELL_VERSION = "shell-
|
|
765
|
+
const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v64";
|
|
754
766
|
|
|
755
767
|
function legacyDefaultMcpAppUri(config: MCPConfig, actionName: string): string {
|
|
756
768
|
const app = safeUiSegment(config.appId ?? config.name, "agent-native");
|
|
@@ -1546,12 +1558,24 @@ export async function createMCPServerForRequest(
|
|
|
1546
1558
|
// branch below degrades to the plain deep-link artifacts the tool would
|
|
1547
1559
|
// otherwise return — no `openai/outputTemplate`, no minted embed-start,
|
|
1548
1560
|
// no embed structuredContent — so the host shows a link, not an iframe.
|
|
1549
|
-
const
|
|
1561
|
+
const mcpAppResourceCandidate = requestMeta?.inlineMcpApps
|
|
1550
1562
|
? await resolveMcpAppResourceSafely(config, name, entry, requestMeta)
|
|
1551
1563
|
: null;
|
|
1552
|
-
const rawResultForClient =
|
|
1564
|
+
const rawResultForClient = mcpAppResourceCandidate
|
|
1553
1565
|
? await withServerMintedMcpAppEmbedStart(rawResult, requestMeta)
|
|
1554
1566
|
: rawResult;
|
|
1567
|
+
// Only attach the embed widget for a non-error result that has content.
|
|
1568
|
+
const embedHasContent = mcpResultHasContent(rawResultForClient);
|
|
1569
|
+
const mcpAppResource =
|
|
1570
|
+
mcpAppResourceCandidate && !mcpResultIsError && embedHasContent
|
|
1571
|
+
? mcpAppResourceCandidate
|
|
1572
|
+
: null;
|
|
1573
|
+
// `openai/outputTemplate` is declared at the tool level, so the host
|
|
1574
|
+
// renders a widget for every call regardless of result _meta. The only
|
|
1575
|
+
// per-result signal it honors is `isError` (shows error text, no widget),
|
|
1576
|
+
// so treat an embed tool that produced nothing as an error.
|
|
1577
|
+
const embedProducedNothing =
|
|
1578
|
+
!!mcpAppResourceCandidate && !mcpResultIsError && !embedHasContent;
|
|
1555
1579
|
const { block, _meta } = buildLinkArtifacts(
|
|
1556
1580
|
entry,
|
|
1557
1581
|
(args as Record<string, any>) ?? {},
|
|
@@ -1590,7 +1614,9 @@ export async function createMCPServerForRequest(
|
|
|
1590
1614
|
if (block) content.push(block);
|
|
1591
1615
|
return {
|
|
1592
1616
|
content,
|
|
1593
|
-
...(mcpResultIsError
|
|
1617
|
+
...(mcpResultIsError || embedProducedNothing
|
|
1618
|
+
? { isError: true }
|
|
1619
|
+
: {}),
|
|
1594
1620
|
...(structuredContent ? { structuredContent } : {}),
|
|
1595
1621
|
...(Object.keys(responseMeta).length > 0
|
|
1596
1622
|
? { _meta: responseMeta }
|
|
@@ -138,6 +138,7 @@ export function embedApp(
|
|
|
138
138
|
let appFrameLoadTimer = null;
|
|
139
139
|
let lastFrameSrc = "";
|
|
140
140
|
let embedSessionRefreshAttempts = 0;
|
|
141
|
+
let reportedContentHeight = 0;
|
|
141
142
|
|
|
142
143
|
function esc(value) {
|
|
143
144
|
return String(value ?? "")
|
|
@@ -174,10 +175,18 @@ export function embedApp(
|
|
|
174
175
|
function visibleIntrinsicHeight() {
|
|
175
176
|
const context = hostState().context || {};
|
|
176
177
|
const hostMaxHeight = contextMaxHeight(context);
|
|
178
|
+
// Size to the app-reported content height (the shell can't measure the
|
|
179
|
+
// cross-origin frame). Don't clamp to the host maxHeight — on Codex it's a
|
|
180
|
+
// small inline hint (~360) that clips the plan; applyIntrinsicHeight still
|
|
181
|
+
// caps at defaultIntrinsicHeight.
|
|
182
|
+
if (reportedContentHeight > 0) {
|
|
183
|
+
return Math.floor(reportedContentHeight + chromeHeight);
|
|
184
|
+
}
|
|
177
185
|
if (hostMaxHeight) return Math.floor(hostMaxHeight);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
// No host maxHeight and no content height yet: use the CONFIGURED height,
|
|
187
|
+
// never window.innerHeight. Echoing the iframe's own size pins us to the
|
|
188
|
+
// current frame height (circular) and locks the embed at the cap forever.
|
|
189
|
+
return Math.floor(defaultIntrinsicHeight);
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
function applyIntrinsicHeight(nextHeight) {
|
|
@@ -1356,7 +1365,8 @@ export function embedApp(
|
|
|
1356
1365
|
}
|
|
1357
1366
|
|
|
1358
1367
|
function notifyHostHeight() {
|
|
1359
|
-
const
|
|
1368
|
+
const intrinsic = visibleIntrinsicHeight();
|
|
1369
|
+
const height = applyIntrinsicHeight(intrinsic);
|
|
1360
1370
|
if (!openAiBridge || typeof openAiBridge.notifyIntrinsicHeight !== "function") {
|
|
1361
1371
|
if (app && typeof app.sendSizeChanged === "function") {
|
|
1362
1372
|
try {
|
|
@@ -1487,6 +1497,14 @@ export function embedApp(
|
|
|
1487
1497
|
notifyOuterMcpAppReady();
|
|
1488
1498
|
return;
|
|
1489
1499
|
}
|
|
1500
|
+
if (event.data.type === "agentNative.contentHeight") {
|
|
1501
|
+
const next = finiteNumber(data && data.height);
|
|
1502
|
+
if (next && Math.abs(next - reportedContentHeight) >= 1) {
|
|
1503
|
+
reportedContentHeight = next;
|
|
1504
|
+
notifyHostHeightSoon();
|
|
1505
|
+
}
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1490
1508
|
if (event.data.type === "agentNative.embedSessionExpired") {
|
|
1491
1509
|
refreshExpiredEmbedSession();
|
|
1492
1510
|
return;
|
|
@@ -1538,12 +1556,9 @@ export function embedApp(
|
|
|
1538
1556
|
const selfNavigate = shouldSelfNavigateToApp();
|
|
1539
1557
|
if (startedFor === launchUrl) return;
|
|
1540
1558
|
startedFor = launchUrl;
|
|
1541
|
-
// Only show
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
// the stage on every redundant launch, so the app rendered and was
|
|
1545
|
-
// instantly blanked back to "Loading app" forever. renderFrame()/
|
|
1546
|
-
// transplant replace the stage themselves when they actually (re)mount.
|
|
1559
|
+
// Only show "Loading app" when no frame is mounted. set_globals
|
|
1560
|
+
// re-launches constantly, so calling it every time would wipe the
|
|
1561
|
+
// just-mounted iframe; renderFrame/transplant own the stage on (re)mount.
|
|
1547
1562
|
if (!appFrame) {
|
|
1548
1563
|
setMessage("Loading app");
|
|
1549
1564
|
}
|
|
@@ -1671,14 +1686,11 @@ export function embedApp(
|
|
|
1671
1686
|
toolResultData = objectValue(data);
|
|
1672
1687
|
openUrl = openLinkFrom(params, data);
|
|
1673
1688
|
openStartUrl = embedStartUrlFrom(params, data);
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1679
|
-
// the host round-trips + (re)launch when something we care about changed.
|
|
1680
|
-
// Deliberately EXCLUDE bridge.maxHeight: the host mutates it on every
|
|
1681
|
-
// set_globals as it echoes our height back, which would defeat the guard.
|
|
1689
|
+
// set_globals fires constantly, and this sync calls notifyHostHeight/
|
|
1690
|
+
// sendHostContext which the host echoes back as another set_globals — an
|
|
1691
|
+
// infinite storm. Only do the host round-trips + (re)launch when something
|
|
1692
|
+
// we care about changed. Exclude maxHeight: the host mutates it every
|
|
1693
|
+
// event, which would defeat the guard.
|
|
1682
1694
|
let signature;
|
|
1683
1695
|
try {
|
|
1684
1696
|
signature = JSON.stringify([
|
|
@@ -1704,6 +1716,9 @@ export function embedApp(
|
|
|
1704
1716
|
if (openUrl || openStartUrl) {
|
|
1705
1717
|
void launchEmbed();
|
|
1706
1718
|
} else if (!appFrame) {
|
|
1719
|
+
// No open URL yet — a content-less result no longer reaches the host as
|
|
1720
|
+
// a widget (build-server marks it isError), so this is the transient
|
|
1721
|
+
// "waiting for the embed to resolve" state, not an empty box.
|
|
1707
1722
|
setMessage("Waiting for app result");
|
|
1708
1723
|
}
|
|
1709
1724
|
return true;
|