Skip to content

Commit 4fad3bf

Browse files
authored
Merge branch 'main' into fix(webapp)-bug-with-task-search
2 parents 2d526d7 + 723c994 commit 4fad3bf

12 files changed

Lines changed: 415 additions & 39 deletions

.github/workflows/publish-docs.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: 📚 Publish docs
2+
3+
on:
4+
push:
5+
tags:
6+
- "docs-release-*"
7+
8+
# Only needs to move the docs-live ref; Mintlify's GitHub app deploys from it.
9+
permissions:
10+
contents: write
11+
12+
concurrency:
13+
group: publish-docs
14+
cancel-in-progress: false
15+
16+
jobs:
17+
publish:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: 📥 Checkout tagged commit
21+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22+
with:
23+
persist-credentials: false
24+
25+
- name: 🔗 Check for broken links
26+
working-directory: ./docs
27+
run: npx mintlify@4.0.393 broken-links
28+
29+
- name: 🚀 Fast-forward docs-live to the tagged commit
30+
env:
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
run: |
33+
gh api -X PATCH \
34+
"repos/${{ github.repository }}/git/refs/heads/docs-live" \
35+
-f sha="${{ github.sha }}" \
36+
-F force=false
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix Vercel env var sync and onboarding preview leaking reserved `TRIGGER_*` keys.

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
envTypeToVercelTarget,
2828
} from "~/v3/vercel/vercelProjectIntegrationSchema";
2929
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
30+
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
3031
import {
3132
callVercelWithRecovery,
3233
wrapVercelCallWithRecovery,
@@ -1350,10 +1351,9 @@ export class VercelIntegrationRepository {
13501351
for (const mapping of envMapping) {
13511352
const iterResult = await ResultAsync.fromPromise(
13521353
(async () => {
1353-
// Build filter to avoid decrypting vars that will be filtered out anyway
1354-
const excludeKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
1354+
// Exclude reserved keys before decrypting (a reserved-only batch gets rejected).
13551355
const shouldIncludeKey = (key: string) =>
1356-
!excludeKeys.has(key) &&
1356+
!isReservedForExternalSync(key) &&
13571357
shouldSyncEnvVar(params.syncEnvVarsMapping, key, mapping.triggerEnvType as TriggerEnvironmentType);
13581358

13591359
const envVarsResult = await this.getVercelEnvironmentVariableValues(
@@ -1399,7 +1399,7 @@ export class VercelIntegrationRepository {
13991399
if (envVar.isSecret) {
14001400
return false;
14011401
}
1402-
if (envVar.key === "TRIGGER_SECRET_KEY" || envVar.key === "TRIGGER_VERSION") {
1402+
if (isReservedForExternalSync(envVar.key)) {
14031403
return false;
14041404
}
14051405
return shouldSyncEnvVar(

apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "~/models/vercelIntegration.server";
1111
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
1212
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
13+
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
1314
import {
1415
VercelProjectIntegrationDataSchema,
1516
VercelProjectIntegrationData,
@@ -567,12 +568,11 @@ export class VercelSettingsPresenter extends BasePresenter {
567568
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
568569
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];
569570

570-
// Filter out TRIGGER_SECRET_KEY and TRIGGER_VERSION (managed by Trigger.dev) and merge project + shared env vars
571-
const excludedKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
571+
// Hide platform-managed reserved keys from the onboarding preview.
572572
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
573573
const mergedEnvVars: VercelEnvironmentVariable[] = [
574574
...projectEnvVars
575-
.filter((v) => !excludedKeys.has(v.key))
575+
.filter((v) => !isReservedForExternalSync(v.key))
576576
.map((v) => {
577577
const envVar = { ...v };
578578
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
@@ -581,7 +581,7 @@ export class VercelSettingsPresenter extends BasePresenter {
581581
return envVar;
582582
}),
583583
...sharedEnvVars
584-
.filter((v) => !projectEnvVarKeys.has(v.key) && !excludedKeys.has(v.key))
584+
.filter((v) => !projectEnvVarKeys.has(v.key) && !isReservedForExternalSync(v.key))
585585
.map((v) => {
586586
const envVar = {
587587
id: v.id,

apps/webapp/app/v3/environmentVariableRules.server.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,37 @@ const blacklistedVariables: VariableRule[] = [
1010
{ type: "exact", key: "TRIGGER_API_URL" },
1111
];
1212

13+
const additionalExternalSyncReservedKeys = ["TRIGGER_VERSION", "TRIGGER_PREVIEW_BRANCH"];
14+
15+
export function isBlacklistedVariable(key: string): boolean {
16+
const whitelisted = blacklistedVariables.find((bv) => bv.type === "whitelist" && bv.key === key);
17+
if (whitelisted) {
18+
return false;
19+
}
20+
21+
const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === key);
22+
if (exact) {
23+
return true;
24+
}
25+
26+
const prefix = blacklistedVariables.find(
27+
(bv) => bv.type === "prefix" && key.startsWith(bv.prefix)
28+
);
29+
if (prefix) {
30+
return true;
31+
}
32+
33+
return false;
34+
}
35+
36+
// Keys that must never be synced from an external integration (e.g. Vercel). Superset of
37+
// the repository blacklist so submitting a reserved key doesn't get the whole batch rejected.
38+
export function isReservedForExternalSync(key: string): boolean {
39+
return isBlacklistedVariable(key) || additionalExternalSyncReservedKeys.includes(key);
40+
}
41+
1342
export function removeBlacklistedVariables(
1443
variables: EnvironmentVariable[]
1544
): EnvironmentVariable[] {
16-
return variables.filter((v) => {
17-
const whitelisted = blacklistedVariables.find(
18-
(bv) => bv.type === "whitelist" && bv.key === v.key
19-
);
20-
if (whitelisted) {
21-
return true;
22-
}
23-
24-
const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key);
25-
if (exact) {
26-
return false;
27-
}
28-
29-
const prefix = blacklistedVariables.find(
30-
(bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix)
31-
);
32-
if (prefix) {
33-
return false;
34-
}
35-
36-
return true;
37-
});
45+
return variables.filter((v) => !isBlacklistedVariable(v.key));
3846
}

apps/webapp/test/environmentVariableRules.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect } from "vitest";
22
import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
3-
import { removeBlacklistedVariables } from "~/v3/environmentVariableRules.server";
3+
import {
4+
isBlacklistedVariable,
5+
isReservedForExternalSync,
6+
removeBlacklistedVariables,
7+
} from "~/v3/environmentVariableRules.server";
48

59
describe("removeBlacklistedVariables", () => {
610
it("should remove exact match blacklisted variables", () => {
@@ -68,3 +72,32 @@ describe("removeBlacklistedVariables", () => {
6872
]);
6973
});
7074
});
75+
76+
describe("isBlacklistedVariable", () => {
77+
it("blacklists the platform-managed keys", () => {
78+
expect(isBlacklistedVariable("TRIGGER_SECRET_KEY")).toBe(true);
79+
expect(isBlacklistedVariable("TRIGGER_API_URL")).toBe(true);
80+
});
81+
82+
it("allows ordinary user keys", () => {
83+
expect(isBlacklistedVariable("DATABASE_URL")).toBe(false);
84+
expect(isBlacklistedVariable("MY_API_KEY")).toBe(false);
85+
});
86+
});
87+
88+
describe("isReservedForExternalSync", () => {
89+
it("reserves every key the repository would reject", () => {
90+
expect(isReservedForExternalSync("TRIGGER_SECRET_KEY")).toBe(true);
91+
expect(isReservedForExternalSync("TRIGGER_API_URL")).toBe(true);
92+
});
93+
94+
it("reserves deploy-managed keys that are not blacklisted", () => {
95+
expect(isReservedForExternalSync("TRIGGER_VERSION")).toBe(true);
96+
expect(isReservedForExternalSync("TRIGGER_PREVIEW_BRANCH")).toBe(true);
97+
});
98+
99+
it("does not reserve ordinary user keys", () => {
100+
expect(isReservedForExternalSync("DATABASE_URL")).toBe(false);
101+
expect(isReservedForExternalSync("MY_API_KEY")).toBe(false);
102+
});
103+
});

docs/ai-chat/custom-agents.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ Each turn yielded by the iterator provides:
114114
| `continuation` | `boolean` | Whether this is a continuation run |
115115
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
116116
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all completed turns |
117+
| `handover` | `{ isFinal: boolean } \| null` | The [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |
117118

118119
| Method | Description |
119120
| ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
120-
| `turn.complete(source)` | Pipe stream, capture response, accumulate, and signal turn-complete |
121+
| `turn.complete(source?)` | Pipe stream, capture response, accumulate, and signal turn-complete. Call with no source on a final head-start handover (`turn.handover.isFinal`), where the warm step-1 partial is already the response |
121122
| `turn.done()` | Signal turn-complete only (when you have piped manually) |
122123
| `turn.addResponse(response)` | Add a response to the accumulator manually |
123124
| `turn.setMessages(uiMessages)`| Replace the accumulated messages — continuation seeding and on-demand compaction |

docs/ai-chat/fast-starts.mdx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ if (payload.trigger === "preload") {
108108

109109
## Head Start
110110

111-
Head Start runs step 1's LLM call in your warm server process while the chat.agent run boots in parallel. The user sees one continuous turn: text first from your server, then a clean handover to the agent for tool execution and any further steps.
111+
Head Start runs step 1's LLM call in your warm server process while the agent run boots in parallel. The user sees one continuous turn: text first from your server, then a clean handover to the agent for tool execution and any further steps. The agent you hand off to can be a `chat.agent`, a `chat.customAgent`, or a `chat.createSession` loop (see [Handover with custom agents](#handover-with-custom-agents)).
112112

113113
`chat.headStart` returns a standard [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) handler — `(req: Request) => Promise<Response>` — so it slots into any runtime that speaks Web Fetch.
114114

@@ -545,16 +545,86 @@ Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemes
545545

546546
Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.
547547

548+
### Handover with custom agents
549+
550+
The route handler is backend-agnostic: `agentId` can point at a `chat.agent`, a [`chat.customAgent`](/ai-chat/custom-agents), or a [`chat.createSession`](/ai-chat/custom-agents#managed-loop-chatcreatesession) loop. With `chat.agent` the handover is consumed for you (the steps above). The two hand-rolled backends consume it explicitly on turn 0.
551+
552+
#### chat.createSession
553+
554+
The turn iterator surfaces the handover as `turn.handover`. On a final (pure-text) handover, call `turn.complete()` with no source to finalize the warm partial without streaming; otherwise stream as usual. The iterator threads the spliced partial as `originalMessages` for you, so a resumed tool round merges into the handed-over assistant.
555+
556+
```ts trigger/chat.ts
557+
for await (const turn of session) {
558+
// Pure-text handover (isFinal): step 1 already IS the response.
559+
const result = turn.handover?.isFinal
560+
? undefined
561+
: streamText({
562+
model: anthropic("claude-sonnet-4-6"),
563+
messages: turn.messages,
564+
abortSignal: turn.signal,
565+
stopWhen: stepCountIs(10),
566+
});
567+
568+
await turn.complete(result); // no source on a final handover
569+
}
570+
```
571+
572+
#### chat.customAgent
573+
574+
In a hand-rolled loop, call `conversation.consumeHandover({ payload })` at the top of turn 0. It waits for the handover signal, seeds prior history from `payload.headStartMessages`, splices the warm step-1 partial into the accumulator, and returns `{ isFinal, skipped }`.
575+
576+
```ts trigger/chat.ts
577+
// Turn 0, gated on a head-start run:
578+
if (turn === 0 && payload.trigger === "handover-prepare") {
579+
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
580+
if (skipped) return; // not a head-start run, or the warm handler aborted — exit
581+
if (!isFinal) {
582+
// The partial carries a pending tool call. Run step 2 to execute it,
583+
// passing originalMessages so the tool output merges into the
584+
// handed-over assistant instead of starting a new message.
585+
const result = streamText({
586+
model: anthropic("claude-sonnet-4-6"),
587+
messages: conversation.modelMessages,
588+
stopWhen: stepCountIs(10),
589+
});
590+
const response = await chat.pipeAndCapture(result, {
591+
originalMessages: conversation.uiMessages,
592+
});
593+
if (response) await conversation.addResponse(response);
594+
}
595+
await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
596+
return;
597+
}
598+
```
599+
600+
Gate the call on `trigger === "handover-prepare"``consumeHandover` consumes the warm handover, not a normal first message. See [Custom agents](/ai-chat/custom-agents) for the full loop (continuation seeding, stop handling, persistence). The lower-level `chat.waitForHandover({ payload })` and `accumulator.applyHandover(signal)` are exported if you need to wait and splice in separate steps.
601+
602+
<Note>
603+
Always pass `originalMessages: conversation.uiMessages` to `pipeAndCapture` in a custom loop. It keeps assistant message IDs stable across turns and lets a tool-approval or handover resume merge into the trailing assistant — the same threading `chat.agent` does internally.
604+
</Note>
605+
548606
### The `chat.headStart` API
549607

550608
```ts
551609
chat.headStart<TTools>({
552-
agentId: string, // The chat.agent({ id }) you're handing off to
610+
agentId: string, // The chat.agent / chat.customAgent id you're handing off to
553611
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
554612
idleTimeoutInSeconds?: number, // How long the agent waits for the handover signal. Default: 60
613+
triggerConfig?: Partial<SessionTriggerConfig>, // Run options for the handover-prepare run
555614
}): (req: Request) => Promise<Response>
556615
```
557616

617+
`triggerConfig` sets run options on the auto-triggered handover-prepare run: `tags`, `queue`, `machine`, `maxAttempts`, `maxDuration`, `region`, and `lockToVersion`. The `chat:{chatId}` tag is prepended automatically. Because the session is created once on the first head-start turn (idempotent on the chat id), this is the only place to set those options for a head-start chat's lifetime, mirroring what [`chat.createStartSessionAction`](/ai-chat/sessions) sets for the direct-trigger path.
618+
619+
```ts lib/chat-handler.ts
620+
export const chatHandler = chat.headStart({
621+
agentId: "my-chat",
622+
triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
623+
run: async ({ chat: helper }) =>
624+
streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
625+
});
626+
```
627+
558628
The `run` callback receives:
559629

560630
- `messages: UIMessage[]` — user messages parsed from the request body.
@@ -599,3 +669,4 @@ This is **not** a stock `useChat` `endpoint` — it's not the canonical request
599669
- [`chat.headStart` factory and types](/ai-chat/reference) — full signatures for `HeadStartRunArgs`, `HeadStartChatHelper`, `HeadStartSession`, `HeadStartHandlerOptions`.
600670
- [`headStart` transport option](/ai-chat/reference#triggerchattransport-options) — alongside `accessToken`, `startSession`, etc.
601671
- [`onPreload` hook](/ai-chat/lifecycle-hooks#onpreload) — the backend hook that fires when a run is preloaded.
672+
- [Custom agents](/ai-chat/custom-agents) — the `chat.customAgent` and `chat.createSession` loops that `consumeHandover` / `turn.handover` plug into.

docs/ai-chat/patterns/tool-result-auditing.mdx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,11 @@ await auditLog.upsert({
112112
## What `extractNewToolResults` returns
113113

114114
```ts
115-
type ExtractedToolResult = {
115+
type ChatNewToolResult = {
116116
toolCallId: string;
117117
toolName: string;
118-
input: unknown; // The arguments the model passed when calling the tool
119-
output?: unknown; // The tool's return value (output-available state)
120-
errorText?: string; // Error message (output-error state)
118+
output: unknown; // The tool's return value (carries the resolved value; in output-error state see errorText)
119+
errorText?: string; // Set iff the part is in output-error state
121120
};
122121
```
123122

0 commit comments

Comments
 (0)