From 62e1d61de40c26d61f56cab40de6f54f47726f31 Mon Sep 17 00:00:00 2001 From: yannickmonney <102406702+yannickmonney@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:45:52 +0000 Subject: [PATCH] fix(platform): scope agent transcripts to their timeline step --- .../components/agent-execution-log.tsx | 12 +- .../components/run-step-timeline.test.tsx | 137 ++++++++++++- .../components/run-step-timeline.tsx | 2 + .../app/lib/backend/automations.test.ts | 27 +++ .../platform/app/lib/backend/automations.ts | 12 +- .../app/lib/backend/contract/sandbox.ts | 2 +- .../automations/stepper.agent-retry.test.ts | 47 +++++ .../backend/core/automations/stepper.ts | 1 + .../backend/domains/sandbox/routes.test.ts | 13 +- .../backend/domains/sandbox/routes.ts | 7 +- .../sandbox/sessions.agent-node-op.test.ts | 182 ++++++++++++++++++ .../backend/domains/sandbox/sessions.ts | 49 ++++- services/platform/lib/engine/core/types.ts | 1 + .../tests/manual/reference/automation.md | 1 + 14 files changed, 473 insertions(+), 20 deletions(-) create mode 100644 services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts diff --git a/services/platform/app/features/automations/components/agent-execution-log.tsx b/services/platform/app/features/automations/components/agent-execution-log.tsx index a5408a543c..f39004db59 100644 --- a/services/platform/app/features/automations/components/agent-execution-log.tsx +++ b/services/platform/app/features/automations/components/agent-execution-log.tsx @@ -89,11 +89,13 @@ 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; @@ -101,7 +103,7 @@ export function AgentActivityLine({ 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'; @@ -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 ( ({ - state: { data: undefined as unknown }, + state: { + data: undefined as unknown, + byNode: undefined as Record | 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: @@ -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( + , + ); + 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( + , + ); + 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); diff --git a/services/platform/app/features/automations/components/run-step-timeline.tsx b/services/platform/app/features/automations/components/run-step-timeline.tsx index a1c890807c..49b514c2de 100644 --- a/services/platform/app/features/automations/components/run-step-timeline.tsx +++ b/services/platform/app/features/automations/components/run-step-timeline.tsx @@ -102,6 +102,7 @@ function TimelineRow({ @@ -122,6 +123,7 @@ function TimelineRow({ )} diff --git a/services/platform/app/lib/backend/automations.test.ts b/services/platform/app/lib/backend/automations.test.ts index 66a228b87d..8f29e64f8c 100644 --- a/services/platform/app/lib/backend/automations.test.ts +++ b/services/platform/app/lib/backend/automations.test.ts @@ -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) => { diff --git a/services/platform/app/lib/backend/automations.ts b/services/platform/app/lib/backend/automations.ts index 00a5b5d2d9..8c57bc13bd 100644 --- a/services/platform/app/lib/backend/automations.ts +++ b/services/platform/app/lib/backend/automations.ts @@ -219,14 +219,22 @@ export const automationReadAdapters: Record = { '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 diff --git a/services/platform/app/lib/backend/contract/sandbox.ts b/services/platform/app/lib/backend/contract/sandbox.ts index d231ea90f5..2825e08626 100644 --- a/services/platform/app/lib/backend/contract/sandbox.ts +++ b/services/platform/app/lib/backend/contract/sandbox.ts @@ -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; diff --git a/services/platform/backend/core/automations/stepper.agent-retry.test.ts b/services/platform/backend/core/automations/stepper.agent-retry.test.ts index d9a4f6d287..6f7ecd029a 100644 --- a/services/platform/backend/core/automations/stepper.agent-retry.test.ts +++ b/services/platform/backend/core/automations/stepper.agent-retry.test.ts @@ -126,6 +126,53 @@ function parkedCursor(suspended: Array>) { } 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({ diff --git a/services/platform/backend/core/automations/stepper.ts b/services/platform/backend/core/automations/stepper.ts index 563bae0873..58937b4867 100644 --- a/services/platform/backend/core/automations/stepper.ts +++ b/services/platform/backend/core/automations/stepper.ts @@ -1216,6 +1216,7 @@ async function stepAgentNode(args: AgentStepArgs): Promise { // 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 diff --git a/services/platform/backend/domains/sandbox/routes.test.ts b/services/platform/backend/domains/sandbox/routes.test.ts index b6414acca9..dba1b5b8ff 100644 --- a/services/platform/backend/domains/sandbox/routes.test.ts +++ b/services/platform/backend/domains/sandbox/routes.test.ts @@ -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. diff --git a/services/platform/backend/domains/sandbox/routes.ts b/services/platform/backend/domains/sandbox/routes.ts index c6e905b106..19cb9f3671 100644 --- a/services/platform/backend/domains/sandbox/routes.ts +++ b/services/platform/backend/domains/sandbox/routes.ts @@ -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 } : {}), + }), }); }); diff --git a/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts b/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts new file mode 100644 index 0000000000..47746653f5 --- /dev/null +++ b/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts @@ -0,0 +1,182 @@ +import type { Sql } from 'postgres'; +import { describe, expect, it, vi } from 'vitest'; + +import { sessionIdForWorkflowExecution } from '../../core/sandbox/session_naming.ts'; +import { getAgentNodeSandboxOp } from './sessions.ts'; + +const operations = [ + { + execId: 'review-exec', + status: 'completed', + progressText: null, + liveTimeline: [{ type: 'text', text: 'SECOND_TRANSCRIPT' }], + modelRef: null, + visionModelRef: null, + startedAt: 2, + finishedAt: 3, + lastEventAt: 3, + }, + { + execId: 'draft-exec', + status: 'completed', + progressText: null, + liveTimeline: [{ type: 'text', text: 'FIRST_TRANSCRIPT' }], + modelRef: null, + visionModelRef: null, + startedAt: 1, + finishedAt: 2, + lastEventAt: 2, + }, +]; + +function database(run: unknown) { + const statements: Array<{ text: string; values: unknown[] }> = []; + const sql = vi.fn(); + sql.mockImplementation( + (strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join('?'); + statements.push({ text, values }); + const rows = text.includes('app.automation_runs') + ? run === null + ? [] + : [run] + : operations + .filter( + (op) => + !text.includes('exec_id =') || + values.at(-1) === null || + op.execId === values.at(-1), + ) + .slice(0, 1); + return Promise.resolve(rows) as never; + }, + ); + return { sql: sql as unknown as Sql, statements }; +} + +const completedRun = { + id: 'run-1', + checkpoints: { + nodes: { + draft_report: { + trace: { node: 'draft_report', type: 'agent', execId: 'draft-exec' }, + }, + review_report: { + trace: { node: 'review_report', type: 'agent', execId: 'review-exec' }, + }, + }, + }, + trace: [], +}; + +describe('agent-node sandbox operation selection', () => { + it.each([ + ['draft_report', 'draft-exec', 'FIRST_TRANSCRIPT'], + ['review_report', 'review-exec', 'SECOND_TRANSCRIPT'], + ])( + 'selects the recorded execution for %s rather than the latest run operation', + async (nodeId, execId, text) => { + const { sql, statements } = database(completedRun); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId, + }), + ).toMatchObject({ execId, liveTimeline: [{ text }] }); + expect(statements[0]?.values).toEqual(['run-1', 'org-1']); + expect(statements[1]?.text).toContain('org_id ='); + expect(statements[1]?.text).toContain('kind ='); + expect(statements[1]?.text).toContain('exec_id ='); + expect(statements[1]?.values).toEqual([ + sessionIdForWorkflowExecution('run-1'), + 'org-1', + 'workflow-agent', + execId, + execId, + ]); + }, + ); + + it('reads the running node s current cursor execution', async () => { + const { sql } = database({ + id: 'run-1', + checkpoints: { + nodes: {}, + cursor: { node: 'draft_report', agent: { execId: 'draft-exec' } }, + }, + trace: [], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toMatchObject({ execId: 'draft-exec' }); + }); + + it('reads a failed node s final trace after the cursor was cleared', async () => { + const { sql } = database({ + id: 'run-1', + checkpoints: { nodes: {} }, + trace: [ + { + node: 'draft_report', + type: 'agent', + status: 'error', + execId: 'draft-exec', + }, + ], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toMatchObject({ execId: 'draft-exec' }); + }); + + it.each(['unknown', '', 'historical', 'no-op'])( + 'does not substitute a later transcript for %s', + async (nodeId) => { + const { sql } = database({ + ...completedRun, + trace: [ + { node: 'historical', type: 'agent' }, + { node: 'no-op', type: 'agent', execId: 'missing-exec' }, + ], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId, + }), + ).toBeNull(); + }, + ); + + it('preserves the latest-operation read for run-wide consumers', async () => { + const { sql } = database(completedRun); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + }), + ).toMatchObject({ execId: 'review-exec' }); + }); + + it('does not query operations for a missing or foreign-organization run', async () => { + const { sql } = database(null); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'foreign-org', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toBeNull(); + expect(sql).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/platform/backend/domains/sandbox/sessions.ts b/services/platform/backend/domains/sandbox/sessions.ts index 2002b2c49e..6b734d2e47 100644 --- a/services/platform/backend/domains/sandbox/sessions.ts +++ b/services/platform/backend/domains/sandbox/sessions.ts @@ -1,6 +1,7 @@ import type { SandboxQuotaConfig } from '@tale/shared/schemas/governance'; import type { Sql, TransactionSql } from 'postgres'; +import { readCheckpoints } from '../../core/automations/checkpoints.ts'; import type { TurnConnectorCaller } from '../../core/node_only/sandbox/connectors_bridge.ts'; import { requireSessionBudgetForOwnerType, @@ -874,20 +875,53 @@ export async function listSandboxViewsForOrg( * the turn, and where it got to). The session id is DERIVED from the run * (`sessionIdForWorkflowExecution`), so the lookup needs no join table. * - * Returns null for a run that is not this org's, or one that has never - * reached its agent node — the log then renders nothing rather than an - * error. + * A node selector binds the read to its durable trace or live cursor's exec. + * Unknown identities (including older traces) fail closed, never borrowing + * another step's log. Omitting the selector preserves the run-wide latest op. + * Returns null for a foreign run or a step without an operation. */ export async function getAgentNodeSandboxOp( sql: Sql, - args: { organizationId: string; runId: string }, + args: { organizationId: string; runId: string; nodeId?: string }, ): Promise | null> { - const runs = await sql<{ id: string }[]>` - SELECT id FROM app.automation_runs + const runs = await sql< + { id: string; checkpoints: unknown; trace: unknown }[] + >` + SELECT id, checkpoints, trace FROM app.automation_runs WHERE id = ${args.runId} AND org_id = ${args.organizationId} LIMIT 1 `; - if (runs[0] === undefined) return null; + const run = runs[0]; + if (run === undefined) return null; + let execId: string | undefined; + if (args.nodeId !== undefined) { + const checkpoints = readCheckpoints(run.checkpoints); + const cursor = checkpoints.cursor; + execId = + cursor?.node === args.nodeId + ? cursor.agent?.execId + : checkpoints.nodes[args.nodeId]?.trace?.execId; + if (execId === undefined && Array.isArray(run.trace)) { + const entry = run.trace.find( + (value: unknown) => + value !== null && + typeof value === 'object' && + 'node' in value && + value.node === args.nodeId && + 'type' in value && + value.type === 'agent', + ); + if ( + entry !== null && + typeof entry === 'object' && + 'execId' in entry && + typeof entry.execId === 'string' + ) { + execId = entry.execId; + } + } + if (typeof execId !== 'string' || execId === '') return null; + } const sessionId = sessionIdForWorkflowExecution(args.runId); const rows = await sql< { @@ -911,6 +945,7 @@ export async function getAgentNodeSandboxOp( FROM app.sandbox_session_ops WHERE session_id = ${sessionId} AND org_id = ${args.organizationId} AND kind = ${WORKFLOW_AGENT_OP_KIND} + AND (${execId ?? null}::text IS NULL OR exec_id = ${execId ?? null}) ORDER BY started_at_ms DESC, id DESC LIMIT 1 `; diff --git a/services/platform/lib/engine/core/types.ts b/services/platform/lib/engine/core/types.ts index 88eccff95c..00e5e613d3 100644 --- a/services/platform/lib/engine/core/types.ts +++ b/services/platform/lib/engine/core/types.ts @@ -157,6 +157,7 @@ export interface NodeTrace { node: string; type: string; status: NodeStatus; + execId?: string; /** Resolved input after template evaluation. */ input?: unknown; output?: unknown; diff --git a/services/platform/tests/manual/reference/automation.md b/services/platform/tests/manual/reference/automation.md index fc880f6ae6..f956ae619d 100644 --- a/services/platform/tests/manual/reference/automation.md +++ b/services/platform/tests/manual/reference/automation.md @@ -14,6 +14,7 @@ Legend: ✅ fully automated · 🔶 partially automated · ⛔ manual-only (no s | Suite | Boxes | Status | Owning spec | |---|---|---|---| +| [automations](../suites/automations.md) | Each expanded sequential agent step reads its own transcript and recorded output; single-agent, non-agent and missing-transcript controls; node-scoped cache keys and HTTP selectors, durable execution identity and unchanged run/project authorization | ✅ component + unit + route | `app/features/automations/components/run-step-timeline.test.tsx`, `app/lib/backend/automations.test.ts`, `backend/domains/sandbox/sessions.agent-node-op.test.ts`, `backend/domains/sandbox/routes.test.ts`, `backend/core/automations/stepper.agent-retry.test.ts` | | [projects](../suites/projects.md) | `PROJ-F3`: successive successful Audience additions retain every saved team before readback; an older read cannot drop a newer acknowledgement; removing a just-saved team and narrowing from acknowledged org-wide access still require confirmation; refused writes preserve the saved audience; matching readback resumes authoritative updates and switching projects resets the local baseline | 🔶 component + real query/mutation lifecycle | `app/features/projects/components/project-sharing-section.test.tsx`, `app/features/projects/components/project-sharing-section.lifecycle.test.tsx`; synthetic transport with actual adapters, TanStack hooks and shared picker; routed browser layout, live permissions and two-session behavior remain manual | | [tasks](../suites/tasks.md) | Reserved comment authors (`agent/workflow`) use the generic localized automation name, not the deleted-agent fallback; en/de/fr and de-CH fallback, live/deleted/system/workflow controls, roster loading, unchanged historical bodies and accessible avatar names, without network access | ✅ hook + component DOM | `app/features/tasks/components/task-comments.actors.test.tsx`; browser layout and focus remain manual | | Backend telemetry | Bounded route/method labels under 20,000 unique inputs; shared rendering for 100 simultaneous scrapes; opt-in HTTP/job spans with independent job roots, failure status, scrubbed metadata and no outbound trace headers | ✅ unit + local HTTP ingestion | `backend/telemetry.test.ts`, `backend/tracing.test.ts`, `backend/env.test.ts`, `backend/error-reporting.test.ts` |