Skip to content

Commit 98826ee

Browse files
committed
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flows-watch
# Conflicts: # apps/webapp/app/components/dashboard-agent/view-actions.test.ts # apps/webapp/app/components/dashboard-agent/view-actions.ts
2 parents 894a722 + 8e0ae47 commit 98826ee

12 files changed

Lines changed: 102 additions & 38 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { stripModelImages } from "./model-markdown";
2323
import { reportBlockFromToolPart } from "./report-block-adapter";
2424
import { shouldShowLiveTurnError } from "./turn-error";
2525
import type { ResolvedUri } from "./ReportView";
26+
import { answerContinuesAfter } from "./view-actions";
2627
import { ViewBlocks } from "./view-catalog";
2728
import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
2829

@@ -246,6 +247,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
246247
onIntent={onIntent}
247248
resolveUri={resolveUri}
248249
pagePaths={pagePaths}
250+
answered={answerContinuesAfter(parts as never, i)}
249251
/>
250252
</ChatCardSlot>
251253
);

apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,14 @@ export function InvestigationCard({
139139
defaultExpanded = false,
140140
resolveUri,
141141
onIntent,
142+
answered = false,
142143
}: {
143144
block: InvestigationBlock;
144145
defaultExpanded?: boolean;
145146
resolveUri?: ResolveUri;
146147
onIntent?: (intent: AgentIntent) => void;
148+
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
149+
answered?: boolean;
147150
}) {
148151
const [expanded, setExpanded] = useState(defaultExpanded);
149152
const investigation = block.investigation;
@@ -237,7 +240,12 @@ export function InvestigationCard({
237240
) : null}
238241
</div>
239242

240-
<InvestigationActions actions={block.capabilities?.actions ?? []} onIntent={onIntent} />
243+
<InvestigationActions
244+
actions={(block.capabilities?.actions ?? []).filter(
245+
(action) => !answered || action.kind !== "ask_follow_up"
246+
)}
247+
onIntent={onIntent}
248+
/>
241249
</AgentCardBody>
242250
</AgentCard>
243251
);

apps/webapp/app/components/dashboard-agent/view-actions.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const watchAction: ActionsBlockAction = {
1616
},
1717
},
1818
};
19+
import { answerContinuesAfter, renderableActions } from "./view-actions";
1920

2021
const askAction: ActionsBlockAction = {
2122
label: "Investigate it",
@@ -75,6 +76,18 @@ describe("one watch button per answer", () => {
7576
{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } },
7677
] as never)
7778
).toEqual([{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } }]);
79+
describe("keep digging, only while there is digging left", () => {
80+
const card = { type: "data-view" };
81+
const text = (t: string) => ({ type: "text", text: t });
82+
83+
it("sees the answer the turn went on to give", () => {
84+
expect(answerContinuesAfter([card, text("so here is why")] as never, 0)).toBe(true);
85+
});
86+
87+
it("leaves a card the turn ended on", () => {
88+
expect(answerContinuesAfter([text("looking"), card] as never, 1)).toBe(false);
89+
// An empty trailing text part is not an answer.
90+
expect(answerContinuesAfter([card, text(" ")] as never, 0)).toBe(false);
7891
});
7992
});
8093

apps/webapp/app/components/dashboard-agent/view-actions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,14 @@ export function cardAlreadyOffersWatch(blocks: ViewBlock[]): boolean {
3232
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
3333
return actions.filter((action) => action.intent.kind !== "watch");
3434
}
35+
36+
/**
37+
* "Keep digging" asks the agent to carry on — which is pointless once it already has.
38+
* A turn that renders an inconclusive card and then keeps answering leaves the button
39+
* offering work that is already done.
40+
*/
41+
export function answerContinuesAfter(parts: { type: string; text?: string }[], index: number) {
42+
return parts
43+
.slice(index + 1)
44+
.some((part) => part.type === "text" && (part.text ?? "").trim().length > 0);
45+
}

apps/webapp/app/components/dashboard-agent/view-catalog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ export function ViewBlocks({
1515
onIntent,
1616
resolveUri,
1717
pagePaths,
18+
answered = false,
1819
}: {
1920
blocks: ViewBlock[];
2021
onIntent?: (intent: AgentIntent) => void;
2122
resolveUri?: (uri: string) => ResolvedUri | null;
2223
pagePaths?: Record<string, string>;
24+
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
25+
answered?: boolean;
2326
}) {
2427
if (!Array.isArray(blocks)) return null;
2528
const rendered = latestRevisionBlocks(blocks);
@@ -52,6 +55,7 @@ export function ViewBlocks({
5255
block={block}
5356
resolveUri={resolveUri}
5457
onIntent={onIntent}
58+
answered={answered}
5559
/>
5660
);
5761
// Host-emitted only, so the model cannot fabricate a confirmation.

apps/webapp/app/services/userActorEnvironment.server.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
*/
1010

1111
import { json } from "@remix-run/server-runtime";
12-
import { type RbacAbility, scopesWithinAbility, type UserActorClaims } from "@trigger.dev/rbac";
12+
import {
13+
buildJwtAbility,
14+
type RbacAbility,
15+
scopesWithinAbility,
16+
type UserActorClaims,
17+
} from "@trigger.dev/rbac";
1318
import { $replica } from "~/db.server";
1419

1520
export const FORBIDDEN_ENVIRONMENT_CODE = "forbidden_environment";
@@ -112,20 +117,28 @@ export async function resolveUserActorEnvironmentScope(
112117
/** Mirrors the RBAC fallback's own default. */
113118
const CAPLESS_USER_ACTOR_SCOPES = ["read:all"];
114119

115-
/** A delegated token must never mint something more capable than itself, so it is the ceiling. */
120+
/**
121+
* A delegated token must never mint something more capable than itself. Two ceilings apply:
122+
* the actor's own ability (their role) and the token's `cap`. The role alone is not enough —
123+
* a read-only agent token belongs to a user who may well be allowed to write.
124+
*/
116125
export function clampUserActorScopes(
117126
requestedScopes: string[] | undefined,
118127
userActor: UserActorClaims,
119128
ability: RbacAbility
120129
): { scopes: string[]; deniedScopes: string[] } {
121-
const requested =
122-
requestedScopes && requestedScopes.length > 0
123-
? requestedScopes
124-
: (userActor.cap ?? CAPLESS_USER_ACTOR_SCOPES);
130+
const cap = userActor.cap ?? CAPLESS_USER_ACTOR_SCOPES;
131+
const requested = requestedScopes && requestedScopes.length > 0 ? requestedScopes : cap;
125132

126-
const { deniedScopes } = scopesWithinAbility(requested, ability);
133+
const denied = new Set([
134+
...scopesWithinAbility(requested, ability).deniedScopes,
135+
...scopesWithinAbility(requested, buildJwtAbility(cap)).deniedScopes,
136+
]);
127137

128-
return { scopes: requested.filter((scope) => !deniedScopes.includes(scope)), deniedScopes };
138+
return {
139+
scopes: requested.filter((scope) => !denied.has(scope)),
140+
deniedScopes: [...denied],
141+
};
129142
}
130143

131144
function assertClaimIsOptional(userActor: UserActorClaims): void {

apps/webapp/test/dashboardAgentDelegatedScopeCeiling.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ it("still hands over the reads the cap does carry", async () => {
5858
expect(clamped.scopes).toEqual(["read:runs"]);
5959
});
6060

61+
it("refuses a write the cap forbids even when the user's role allows it", async () => {
62+
// The cloud path builds the ability from the user's role, not from the token's cap —
63+
// so the role alone would hand a read-only agent token a write JWT.
64+
const { claims } = await abilityFor(AGENT_CAP);
65+
const roleAllowsEverything = { can: () => true, canSuper: () => false } as never;
66+
67+
const clamped = clampUserActorScopes(["write:runs"], claims, roleAllowsEverything);
68+
69+
expect(clamped.scopes).toEqual([]);
70+
expect(clamped.deniedScopes).toContain("write:runs");
71+
});
72+
6173
it("keeps a capless delegated token read-only", async () => {
6274
const { ability, claims } = await abilityFor();
6375

apps/webapp/test/dashboardAgentTranscriptStore.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ describe("invariant 3: an ordinary transcript write can never change a stored me
311311
await persistTurn(agentDb, {
312312
chatId,
313313
messages: [toolMessage("a1", "output-available")],
314+
finalizeMessageIds: ["a1"],
314315
session: { publicAccessToken: "pat_store" },
315316
});
316317

@@ -645,6 +646,8 @@ describe("a write can no longer lose a message another process appended", () =>
645646
await persistTurn(agentDb, {
646647
chatId,
647648
messages: [{ ...(card.message as Record<string, unknown>), tampered: true }],
649+
// Even named outright, a durable event is not this turn's to rewrite.
650+
finalizeMessageIds: [cardId],
648651
session: { publicAccessToken: "pat_store" },
649652
});
650653
const afterCard = (await rows(prisma, chatId)).find(

internal-packages/dashboard-agent-db/drizzle/meta/0002_snapshot.json

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,14 @@
7474
"compositePrimaryKeys": {
7575
"chat_messages_chat_id_message_id_pk": {
7676
"name": "chat_messages_chat_id_message_id_pk",
77-
"columns": [
78-
"chat_id",
79-
"message_id"
80-
]
77+
"columns": ["chat_id", "message_id"]
8178
}
8279
},
8380
"uniqueConstraints": {
8481
"chat_messages_chat_position_key": {
8582
"name": "chat_messages_chat_position_key",
8683
"nullsNotDistinct": false,
87-
"columns": [
88-
"chat_id",
89-
"position"
90-
]
84+
"columns": ["chat_id", "position"]
9185
}
9286
},
9387
"policies": {},
@@ -408,10 +402,7 @@
408402
"compositePrimaryKeys": {
409403
"chat_turn_evals_chat_id_turn_pk": {
410404
"name": "chat_turn_evals_chat_id_turn_pk",
411-
"columns": [
412-
"chat_id",
413-
"turn"
414-
]
405+
"columns": ["chat_id", "turn"]
415406
}
416407
},
417408
"uniqueConstraints": {},
@@ -690,10 +681,7 @@
690681
"compositePrimaryKeys": {
691682
"watch_batches_environment_id_cadence_minutes_pk": {
692683
"name": "watch_batches_environment_id_cadence_minutes_pk",
693-
"columns": [
694-
"environment_id",
695-
"cadence_minutes"
696-
]
684+
"columns": ["environment_id", "cadence_minutes"]
697685
}
698686
},
699687
"uniqueConstraints": {},
@@ -846,10 +834,7 @@
846834
"compositePrimaryKeys": {
847835
"watch_submissions_chat_id_client_request_id_pk": {
848836
"name": "watch_submissions_chat_id_client_request_id_pk",
849-
"columns": [
850-
"chat_id",
851-
"client_request_id"
852-
]
837+
"columns": ["chat_id", "client_request_id"]
853838
}
854839
},
855840
"uniqueConstraints": {},
@@ -1298,4 +1283,4 @@
12981283
"schemas": {},
12991284
"tables": {}
13001285
}
1301-
}
1286+
}

internal-packages/dashboard-agent-db/drizzle/meta/_journal.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@
2424
"breakpoints": true
2525
}
2626
]
27-
}
27+
}

0 commit comments

Comments
 (0)