Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,21 @@ function TypingDots() {
export function AgentActivityLine({
organizationId,
runId,
nodeId,
waitingForRoom = false,
className,
}: {
organizationId: string;
runId: string;
nodeId?: string;
/** The run is parked on the turn's start, waiting for sandbox room. */
waitingForRoom?: boolean;
className?: string;
}) {
const { t } = useT('automations');
const opQuery = useBackendQuery(
'sandbox/session_queries_public:getAgentNodeSandboxOp',
{ organizationId, runId },
{ organizationId, runId, ...(nodeId !== undefined ? { nodeId } : {}) },
);
const op = opQuery.data ?? null;
const live = op !== null && op.status === 'running';
Expand Down Expand Up @@ -401,24 +403,26 @@ export function ExecutionLogView({

/**
* An automation run's agent transcript — {@link ExecutionLogView} bound to
* the run's `workflow-agent` session op. Renders nothing when the run never
* ran an agent node.
* a step's `workflow-agent` session op when nodeId is given, otherwise the
* run's latest op. Renders nothing when the selected step has no known op.
*/
export function AgentExecutionLog({
organizationId,
runId,
nodeId,
waitingForRoom = false,
className,
}: {
organizationId: string;
runId: string;
nodeId?: string;
/** The run is parked on the turn's start, waiting for sandbox room. */
waitingForRoom?: boolean;
className?: string;
}) {
const opQuery = useBackendQuery(
'sandbox/session_queries_public:getAgentNodeSandboxOp',
{ organizationId, runId },
{ organizationId, runId, ...(nodeId !== undefined ? { nodeId } : {}) },
);
return (
<ExecutionLogView
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { render, screen } from '@/tests/utils/render';

Expand All @@ -9,13 +9,38 @@ import { RunStepTimeline } from './run-step-timeline';
// Drives the mocked `useBackendQuery` return: the run's sandbox op, read by
// the agent activity line and the transcript pane inside an unfolded row.
const { state } = vi.hoisted(() => ({
state: { data: undefined as unknown },
state: {
data: undefined as unknown,
byNode: undefined as Record<string, unknown> | undefined,
queries: [] as Array<{
organizationId: string;
runId: string;
nodeId?: string;
}>,
},
}));

vi.mock('@/app/hooks/use-backend-query', () => ({
useBackendQuery: () => ({ data: state.data }),
useBackendQuery: (
_name: string,
args: { organizationId: string; runId: string; nodeId?: string },
) => {
state.queries.push(args);
return {
data:
state.byNode === undefined
? state.data
: (state.byNode[args.nodeId ?? 'latest'] ?? null),
};
},
}));

beforeEach(() => {
state.data = undefined;
state.byNode = undefined;
state.queries = [];
});

const runId = 'run-1' as string;

// Three steps wired by references, so topoSort has an order to find:
Expand Down Expand Up @@ -108,6 +133,112 @@ function renderTimeline(run: unknown, currentNodeId: string | null) {
}

describe('RunStepTimeline', () => {
it('shows each sequential agent step s own transcript beside its recorded output', async () => {
const transcript = (execId: string, text: string) => ({
execId,
status: 'completed',
startedAt: 1,
liveTimeline: [{ type: 'text', text }],
});
state.byNode = {
draft_report: transcript('draft-exec', 'FIRST_NODE_TRANSCRIPT'),
review_report: transcript('review-exec', 'SECOND_NODE_TRANSCRIPT'),
latest: transcript('review-exec', 'SECOND_NODE_TRANSCRIPT'),
};
const { user } = render(
<RunStepTimeline
graph={buildGraph({
name: 'reports',
nodes: [
{ id: 'draft_report', type: 'agent', prompt: 'Draft' },
{
id: 'review_report',
type: 'agent',
prompt: 'Review {{ nodes.draft_report.output }}',
},
],
})}
projection={projectRun({
status: 'success',
trace: [
{
node: 'draft_report',
type: 'agent',
status: 'ok',
output: 'FIRST_NODE_RECORDED_OUTPUT',
},
{
node: 'review_report',
type: 'agent',
status: 'ok',
output: 'SECOND_NODE_RECORDED_OUTPUT',
},
],
})}
currentNodeId={null}
organizationId="org-1"
runId={runId}
/>,
);
expect(state.queries).toHaveLength(0);
await user.click(screen.getByRole('button', { name: /draft report/ }));
expect(
screen.getByRole('button', { name: /draft report/ }).closest('li'),
).toHaveTextContent('FIRST_NODE_RECORDED_OUTPUT');
expect(screen.getByText('FIRST_NODE_TRANSCRIPT')).toBeInTheDocument();
expect(
screen.queryByText('SECOND_NODE_TRANSCRIPT'),
).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /review report/ }));
expect(
screen.getByRole('button', { name: /review report/ }).closest('li'),
).toHaveTextContent('SECOND_NODE_RECORDED_OUTPUT');
expect(screen.getAllByText('SECOND_NODE_TRANSCRIPT')).toHaveLength(1);
expect(screen.getAllByText('FIRST_NODE_TRANSCRIPT')).toHaveLength(1);
expect(state.queries).toContainEqual({
organizationId: 'org-1',
runId,
nodeId: 'draft_report',
});
expect(state.queries).toContainEqual({
organizationId: 'org-1',
runId,
nodeId: 'review_report',
});
});

it('keeps a single agent transcript and leaves a missing transcript empty', async () => {
state.byNode = {
read_invoices: {
execId: 'only-exec',
status: 'completed',
startedAt: 1,
liveTimeline: [{ type: 'text', text: 'ONLY_TRANSCRIPT' }],
},
};
const { user, rerender } = renderTimeline(finishedRun, null);
await user.click(screen.getByRole('button', { name: /read invoices/ }));
expect(screen.getByText('ONLY_TRANSCRIPT')).toBeInTheDocument();
state.byNode = {};
rerender(
<RunStepTimeline
graph={graph}
projection={projectRun(finishedRun)}
currentNodeId={null}
organizationId="org-1"
runId={runId}
/>,
);
expect(screen.queryByText('ONLY_TRANSCRIPT')).not.toBeInTheDocument();
expect(screen.queryByText('Agent log')).not.toBeInTheDocument();
});

it('never queries an agent transcript for an expanded non-agent step', async () => {
const { user } = renderTimeline(finishedRun, null);
await user.click(screen.getByRole('button', { name: /mark started/ }));
expect(state.queries).toHaveLength(0);
expect(screen.queryByText('Agent log')).not.toBeInTheDocument();
});
it('lists every step in execution order, compact by default', () => {
state.data = undefined;
renderTimeline(finishedRun, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function TimelineRow({
<AgentActivityLine
organizationId={organizationId}
runId={runId}
nodeId={nodeId}
waitingForRoom={waitingForRoom}
className="mt-1 pl-6"
/>
Expand All @@ -122,6 +123,7 @@ function TimelineRow({
<AgentExecutionLog
organizationId={organizationId}
runId={runId}
nodeId={nodeId}
waitingForRoom={waitingForRoom}
/>
)}
Expand Down
27 changes: 27 additions & 0 deletions services/platform/app/lib/backend/automations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ afterEach(() => {
delete window.__ENV__;
});

it('separates agent-step transcript cache keys and carries the selector over HTTP', async () => {
const fetchSpy = vi
.spyOn(window, 'fetch')
.mockImplementation(async () =>
jsonResponse(200, { op: { execId: 'draft-exec' } }),
);
const adapter =
automationReadAdapters[
'sandbox/session_queries_public:getAgentNodeSandboxOp'
];
const args = { organizationId: 'org1', runId: 'run/1' };
const draft = adapter?.({ ...args, nodeId: 'draft/report' }, {});
const review = adapter?.({ ...args, nodeId: 'review_report' }, {});
const latest = adapter?.(args, {});
expect(draft?.queryKey).not.toEqual(review?.queryKey);
expect(draft?.queryKey).not.toEqual(latest?.queryKey);
expect(adapter?.({ ...args, nodeId: 123 }, {})).toBeNull();
expect(await draft?.queryFn()).toEqual({ execId: 'draft-exec' });
expect(fetchSpy.mock.calls[0]?.[0]).toBe(
'/api/app/sandbox/agent-node-op?runId=run%2F1&nodeId=draft%2Freport&orgId=org1',
);
await latest?.queryFn();
expect(fetchSpy.mock.calls[1]?.[0]).toBe(
'/api/app/sandbox/agent-node-op?runId=run%2F1&orgId=org1',
);
});

it.each([null, 'p1'])(
'normalizes run project scope %s for continuation pickers',
async (projectId) => {
Expand Down
12 changes: 10 additions & 2 deletions services/platform/app/lib/backend/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,22 @@ export const automationReadAdapters: Record<string, ReadAdapter> = {
'sandbox/session_queries_public:getAgentNodeSandboxOp': (args, ctx) => {
const orgId = orgOf(args, ctx);
const runId = args.runId;
const nodeId = args.nodeId;
if (nodeId !== undefined && typeof nodeId !== 'string') return null;
if (orgId === undefined || typeof runId !== 'string' || runId === '') {
return null;
}
return {
queryKey: backendKey(orgId, 'automation', 'agent-node-op', runId),
queryKey: backendKey(
orgId,
'automation',
'agent-node-op',
runId,
...(typeof nodeId === 'string' ? [nodeId] : []),
),
queryFn: () =>
backendFetch<{ op: unknown }>(
`/sandbox/agent-node-op?runId=${encodeURIComponent(runId)}`,
`/sandbox/agent-node-op?runId=${encodeURIComponent(runId)}${typeof nodeId === 'string' ? `&nodeId=${encodeURIComponent(nodeId)}` : ''}`,
{ orgId },
).then((body) => body.op),
// The execution log follows a LIVE turn: poll while the dialog is
Expand Down
2 changes: 1 addition & 1 deletion services/platform/app/lib/backend/contract/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface SandboxContract {
};
'sandbox/session_queries_public:getAgentNodeSandboxOp': {
kind: 'query';
args: { organizationId: string; runId: string };
args: { organizationId: string; runId: string; nodeId?: string };
returns: null | {
lastEventAt?: number;
finishedAt?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,53 @@ function parkedCursor(suspended: Array<Record<string, unknown>>) {
}

describe('the stepper re-kicking a failed agent attempt', () => {
it('records the settled agent execution identity in the durable node trace', async () => {
const { ctx, finished } = harness(
parkedAttempt({
execId: 'final-exec',
result: { errored: false, text: 'distinct reply', files: [] },
}),
);
await stepRunImpl(ctx, RUN);
expect(finished[0]).toMatchObject({
status: 'success',
trace: [
{
node: 'repair',
type: 'agent',
execId: 'final-exec',
output: { text: 'distinct reply' },
},
],
});
});

it('retains the failed agent execution identity in the final error trace', async () => {
const { ctx, finished } = harness(
parkedAttempt({
execId: 'failed-exec',
result: {
errored: true,
reason: 'budget used',
failureCode: 'budget_exceeded',
text: '',
files: [],
},
}),
);
await stepRunImpl(ctx, RUN);
expect(finished[0]).toMatchObject({
status: 'failed',
trace: [
{
node: 'repair',
type: 'agent',
execId: 'failed-exec',
status: 'error',
},
],
});
});
it('resumes a credential rotation on the same account pool, without spending an attempt', async () => {
const { ctx, kicks, suspended } = harness(
parkedAttempt({
Expand Down
1 change: 1 addition & 0 deletions services/platform/backend/core/automations/stepper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,7 @@ async function stepAgentNode(args: AgentStepArgs): Promise<StepOutcome> {
// Parked: the resolved request recorded at kick time is this entry's trace
// input and effect, whatever happens next — the turn ran either way.
trace.input = parked.input;
trace.execId = parked.execId;
effects.push({ node: node.id, connector: 'agent', input: parked.input });

// The settle may have landed after this turn loaded its checkpoints, so a
Expand Down
13 changes: 11 additions & 2 deletions services/platform/backend/domains/sandbox/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,14 +416,23 @@ describe('agent-node op honours the run project read rule', () => {
});

it('answers the op for a project run the member can read', async () => {
const res = await app().request('/agent-node-op?runId=r-visible');
const res = await app().request(
'/agent-node-op?runId=r-visible&nodeId=draft_report',
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ op });
expect(agentNodeOp).toHaveBeenCalledTimes(1);
expect(agentNodeOp).toHaveBeenCalledWith(query, {
organizationId: 'member-org',
runId: 'r-visible',
nodeId: 'draft_report',
});
});

it('hides the op of a run whose project the member cannot read', async () => {
const res = await app().request('/agent-node-op?runId=r-hidden');
const res = await app().request(
'/agent-node-op?runId=r-hidden&nodeId=draft_report',
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ op: null });
// The transcript read never runs for a hidden run.
Expand Down
7 changes: 6 additions & 1 deletion services/platform/backend/domains/sandbox/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,13 @@ export function createSandboxRoutes(deps: {
role: c.get('orgMember').role,
});
if (!(await canReadRun(deps.sql, auth, run))) return c.json({ op: null });
const nodeId = c.req.query('nodeId');
return c.json({
op: await getAgentNodeSandboxOp(deps.sql, { organizationId, runId }),
op: await getAgentNodeSandboxOp(deps.sql, {
organizationId,
runId,
...(nodeId !== undefined ? { nodeId } : {}),
}),
});
});

Expand Down
Loading
Loading