Skip to content

Commit 9dbc5f9

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(sdk,webapp): tag chat sessions, and tag dashboard agent chats by org, project, env and user
```ts await startChatSession({ chatId, clientData, tags: [`org:${org.slug}`, `user:${user.id}`], // the Session's own tags triggerConfig: { tags: [`org:${org.slug}`] }, // tags on its runs, as before }); ``` `chat.createStartSessionAction`, `chat.headStart` and `chat.startHeadStart` now accept `tags` for the Session itself. Until now they only forwarded `triggerConfig.tags`, which tag the session's runs, so chat sessions couldn't be filtered on the Sessions page. The dashboard agent uses this to tag every chat's session and runs by organization, project, environment and user. Run tags come from the session's trigger config, so runs the session starts later carry them too. Mono-RevId: af4ac8b786f6f6b13b2f5c5aab8a6af4e2ab5113
1 parent 028bc76 commit 9dbc5f9

11 files changed

Lines changed: 187 additions & 4 deletions

‎.changeset/chat-session-tags.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`chat.createStartSessionAction`, `chat.headStart` and `chat.startHeadStart` now accept `tags` for the Session itself, so chat sessions can be filtered by tag on the Sessions page. `triggerConfig.tags` still tags the session's runs.
6+
7+
```ts
8+
await startChatSession({ chatId, clientData, tags: [`org:${org.slug}`, `user:${user.id}`] });
9+
```

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
resolveDashboardAgentRepoSnapshot,
5252
startDashboardAgentSession,
5353
} from "~/services/dashboardAgent.server";
54+
import { dashboardAgentTags } from "~/services/dashboardAgentTags";
5455
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
5556
import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server";
5657
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
@@ -366,6 +367,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
366367
const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, userId);
367368
if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 });
368369
const environmentAddress = dashboardAgentEnvironmentAddress(runtimeEnv);
370+
const tags = dashboardAgentTags({
371+
organizationSlug,
372+
projectRef: project.externalRef,
373+
environmentSlug: runtimeEnv.slug,
374+
userId,
375+
});
369376

370377
const chatId = generateFriendlyId("chat");
371378
try {
@@ -416,6 +423,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
416423
mode: repoSnapshot ? "code" : "assistant",
417424
metadata: headStartMetadata,
418425
watchEnabled,
426+
tags,
419427
});
420428
} else {
421429
// Cold start: the client sends the first message through the `in` proxy, which
@@ -424,6 +432,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
424432
// delegated token on the first turn.
425433
await startDashboardAgentSession({
426434
chatId,
435+
tags,
427436
clientData: {
428437
...clientContext,
429438
watchEnabled,
@@ -660,6 +669,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
660669
// is fetched and extracted on the worker).
661670
await startDashboardAgentSession({
662671
chatId,
672+
tags: dashboardAgentTags({
673+
organizationSlug,
674+
projectRef: project.externalRef,
675+
environmentSlug: runtimeEnv.slug,
676+
userId,
677+
}),
663678
clientData: {
664679
...pickAgentClientMetadata(clientData),
665680
// Server-resolved, like every other field here: the resumed run's first turn

‎apps/webapp/app/services/dashboardAgent.server.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,15 @@ export function isDashboardAgentConfigured(): boolean {
101101
// when DASHBOARD_AGENT_VERSION is set; unset runs on the env's current version.
102102
// `ttl` always applies, so an agent run that never gets dequeued expires
103103
// instead of sitting in the queue indefinitely.
104-
export function dashboardAgentTriggerConfig(): {
104+
export function dashboardAgentTriggerConfig(tags: string[] = []): {
105105
lockToVersion?: string;
106106
ttl: string;
107+
tags?: string[];
107108
} {
108109
return {
109110
...(env.DASHBOARD_AGENT_VERSION ? { lockToVersion: env.DASHBOARD_AGENT_VERSION } : {}),
110111
ttl: env.DASHBOARD_AGENT_RUN_TTL,
112+
...(tags.length > 0 ? { tags } : {}),
111113
};
112114
}
113115

@@ -116,14 +118,19 @@ export function dashboardAgentTriggerConfig(): {
116118
export async function startDashboardAgentSession(params: {
117119
chatId: string;
118120
clientData?: Record<string, unknown>;
121+
tags?: string[];
119122
}): Promise<void> {
120123
const config = dashboardAgentConfig();
121124
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
122125
const startSession = chat.createStartSessionAction(TASK_ID, {
123126
apiClient: config,
124-
triggerConfig: dashboardAgentTriggerConfig(),
127+
triggerConfig: dashboardAgentTriggerConfig(params.tags),
128+
});
129+
await startSession({
130+
chatId: params.chatId,
131+
clientData: params.clientData,
132+
...(params.tags ? { tags: params.tags } : {}),
125133
});
126-
await startSession({ chatId: params.chatId, clientData: params.clientData });
127134
}
128135

129136
// Read is narrowed to the `.out` stream (`read:sessions:{chatId}:out`): `.in` records carry

‎apps/webapp/app/services/dashboardAgentHeadStart.server.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export async function startDashboardAgentHeadStart(params: {
9191
mode: "assistant" | "code";
9292
metadata: Record<string, unknown>;
9393
watchEnabled: boolean;
94+
tags?: string[];
9495
}): Promise<void> {
9596
// The same assembly the agent run uses, so both sides hand the provider one prefix.
9697
const tools = toolSchemasFor(params.mode, { watchEnabled: params.watchEnabled });
@@ -101,7 +102,8 @@ export async function startDashboardAgentHeadStart(params: {
101102
chatId: params.chatId,
102103
messages: params.messages,
103104
metadata: params.metadata,
104-
triggerConfig: dashboardAgentTriggerConfig(),
105+
triggerConfig: dashboardAgentTriggerConfig(params.tags),
106+
...(params.tags ? { tags: params.tags } : {}),
105107
// Scopes session creation and the agent trigger to the agent's own environment.
106108
apiClient: {
107109
baseURL: dashboardAgentApiOrigin(),
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createHash } from "node:crypto";
2+
3+
const MAX_TAG_LENGTH = 128;
4+
const HASH_LENGTH = 8;
5+
6+
/** A tag over the limit keeps its prefix and swaps the tail for a hash of the whole, so two long values never share one. */
7+
function boundedTag(tag: string): string {
8+
if (tag.length <= MAX_TAG_LENGTH) return tag;
9+
const hash = createHash("sha256").update(tag).digest("hex").slice(0, HASH_LENGTH);
10+
return `${tag.slice(0, MAX_TAG_LENGTH - HASH_LENGTH - 1)}~${hash}`;
11+
}
12+
13+
/**
14+
* Tags for finding a chat's session and runs in the agent project's dashboard by who
15+
* started it and where. Session and run tags both get them.
16+
*/
17+
export function dashboardAgentTags(params: {
18+
organizationSlug: string;
19+
projectRef: string;
20+
environmentSlug: string;
21+
userId: string;
22+
}): string[] {
23+
return [
24+
`org:${params.organizationSlug}`,
25+
`project:${params.projectRef}`,
26+
`env:${params.environmentSlug}`,
27+
`user:${params.userId}`,
28+
].map(boundedTag);
29+
}

‎apps/webapp/test/dashboardAgentClientMetadata.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,30 @@ async function startChat(clientData: Record<string, unknown>) {
119119
return mocks.startSession.mock.calls[0][0].clientData as Record<string, unknown>;
120120
}
121121

122+
describe("dashboard agent start intent — tags", () => {
123+
beforeEach(() => {
124+
mocks.findEnvironmentBySlug.mockReset().mockResolvedValue({
125+
id: "env_real",
126+
slug: "dev",
127+
type: "DEVELOPMENT",
128+
branchName: null,
129+
});
130+
mocks.chatExists.mockReset().mockResolvedValue(true);
131+
mocks.startSession.mockReset().mockResolvedValue(undefined);
132+
});
133+
134+
it("tags the session it starts for an existing chat like a new one", async () => {
135+
await startChat({});
136+
137+
expect(mocks.startSession.mock.calls[0][0].tags).toEqual([
138+
"org:acme",
139+
"project:proj_ref_real",
140+
"env:dev",
141+
"user:usr_real",
142+
]);
143+
});
144+
});
145+
122146
describe.each([
123147
["in proxy", appendTurn],
124148
["start intent", startChat],
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "vitest";
2+
import { dashboardAgentTags } from "~/services/dashboardAgentTags";
3+
4+
const SCOPE = { organizationSlug: "acme", projectRef: "proj_abc", userId: "user_1" };
5+
6+
describe("dashboard agent tags", () => {
7+
it("tags a chat by organization, project, environment and user", () => {
8+
expect(dashboardAgentTags({ ...SCOPE, environmentSlug: "prod" })).toEqual([
9+
"org:acme",
10+
"project:proj_abc",
11+
"env:prod",
12+
"user:user_1",
13+
]);
14+
});
15+
16+
it("keeps two long branch slugs with a shared prefix apart, within the limit", () => {
17+
const shared = "feature-".repeat(20);
18+
const [alpha] = dashboardAgentTags({ ...SCOPE, environmentSlug: `${shared}alpha` }).filter(
19+
(tag) => tag.startsWith("env:")
20+
);
21+
const [beta] = dashboardAgentTags({ ...SCOPE, environmentSlug: `${shared}beta` }).filter(
22+
(tag) => tag.startsWith("env:")
23+
);
24+
25+
expect(alpha!.length).toBeLessThanOrEqual(128);
26+
expect(beta!.length).toBeLessThanOrEqual(128);
27+
expect(alpha).not.toBe(beta);
28+
expect(dashboardAgentTags({ ...SCOPE, environmentSlug: `${shared}alpha` })).toContain(alpha);
29+
});
30+
});

‎packages/trigger-sdk/src/v3/ai.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13204,6 +13204,11 @@ export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
1320413204
* server-side metadata that doesn't go through the agent's `clientDataSchema`.
1320513205
*/
1320613206
metadata?: Record<string, unknown>;
13207+
/**
13208+
* Up to 10 tags on the Session row, for filtering sessions in the dashboard.
13209+
* Run tags go in `triggerConfig.tags`.
13210+
*/
13211+
tags?: string[];
1320713212
};
1320813213

1320913214
/**
@@ -13361,6 +13366,7 @@ function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
1336113366
taskIdentifier: taskId,
1336213367
triggerConfig,
1336313368
metadata: params.metadata,
13369+
...(params.tags !== undefined ? { tags: params.tags } : {}),
1336413370
};
1336513371

1336613372
const baseURLOption = options?.baseURL;

‎packages/trigger-sdk/src/v3/chat-server.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,36 @@ describe("chat.startHeadStart (detached)", () => {
716716
expect(body.triggerConfig.basePayload.headStartMessages[0].id).toBe("m1");
717717
});
718718

719+
it("puts session tags on the created session", async () => {
720+
const requests: CapturedRequest[] = [];
721+
wireFetch(requests);
722+
723+
const result = await withApiContext(() =>
724+
chat.startHeadStart({
725+
agentId: "test-agent",
726+
chatId: "chat-tags",
727+
messages: userMessages,
728+
tags: ["org:acme", "project:proj_1"],
729+
triggerConfig: { tags: ["org:acme"] },
730+
run: async ({ chat: chatHelper }) =>
731+
streamText({
732+
...chatHelper.toStreamTextOptions(),
733+
model: new MockLanguageModelV3({
734+
doStream: async () => ({ stream: textStream("hi back") }),
735+
}),
736+
}),
737+
})
738+
);
739+
await result.completion;
740+
741+
const sessionCreate = requests.find(
742+
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
743+
);
744+
const body = JSON.parse(sessionCreate!.init!.body as string);
745+
expect(body.tags).toEqual(["org:acme", "project:proj_1"]);
746+
expect(body.triggerConfig.tags).toEqual(["chat:chat-tags", "org:acme"]);
747+
});
748+
719749
it("dispatches a final handover (isFinal: true) on a pure-text step 1", async () => {
720750
const requests: CapturedRequest[] = [];
721751
wireFetch(requests);

‎packages/trigger-sdk/src/v3/chat-server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
285285
* the tag length limit (see `chatRunTags`).
286286
*/
287287
triggerConfig?: Partial<SessionTriggerConfigInput>;
288+
/**
289+
* Up to 10 tags on the Session itself, for filtering sessions in the dashboard.
290+
* Run tags go in `triggerConfig.tags`.
291+
*/
292+
tags?: string[];
288293
/**
289294
* API client config (base URL + access token) for creating the session
290295
* and triggering the agent run. When set, the handler runs under this
@@ -309,6 +314,8 @@ export type StartHeadStartOptions<TTools extends Record<string, Tool>> = {
309314
idleTimeoutInSeconds?: number;
310315
/** Run options for the auto-triggered `handover-prepare` run (tags, queue, machine, …). */
311316
triggerConfig?: Partial<SessionTriggerConfigInput>;
317+
/** Up to 10 tags on the Session itself, for filtering sessions in the dashboard. */
318+
tags?: string[];
312319
/** API client config for session creation + trigger when the agent lives in another project/env. */
313320
apiClient?: ApiClientConfiguration;
314321
/** Metadata merged into the run's wire payload (auth tokens, context, …). Never sent to the browser. */
@@ -352,6 +359,7 @@ export const chat = {
352359
agentId: opts.agentId,
353360
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
354361
triggerConfig: opts.triggerConfig,
362+
tags: opts.tags,
355363
});
356364

357365
const helper: HeadStartChatHelper<TTools> = {
@@ -427,6 +435,7 @@ export const chat = {
427435
agentId: opts.agentId,
428436
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
429437
triggerConfig: opts.triggerConfig,
438+
tags: opts.tags,
430439
});
431440

432441
// Scope session creation + the agent trigger to `apiClient`'s env when
@@ -594,6 +603,8 @@ async function openHandoverSession(opts: {
594603
agentId: string;
595604
idleTimeoutInSeconds?: number;
596605
triggerConfig?: Partial<SessionTriggerConfigInput>;
606+
/** Tags on the Session row. */
607+
tags?: string[];
597608
/** Request-lifecycle signal on the HTTP path; omitted on the detached path. */
598609
requestSignal?: AbortSignal;
599610
}): Promise<InternalSession> {
@@ -674,6 +685,7 @@ async function openHandoverSession(opts: {
674685
externalId: chatId,
675686
taskIdentifier: opts.agentId,
676687
triggerConfig,
688+
...(opts.tags !== undefined ? { tags: opts.tags } : {}),
677689
})
678690
);
679691
const sessionPublicAccessToken = created.publicAccessToken;

0 commit comments

Comments
 (0)