From 0295d911f98421e5c0be7ae16d113355dacc03ff Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 2 Jul 2026 16:19:10 -0300 Subject: [PATCH 1/2] feat(console): register-trigger + state tool views; fix duplicated request pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FunctionCallMessage: suppress the top request pane for every completed call, fixing REQUEST rendering twice on generic (no-custom-view) tool cards. - engine::register_trigger: new rich view — trigger_type → function_id, config chips for state triggers, harness::react model/join(id·key·expect·rearm)/task, plus the harness subscribe variant (label, once, no function_id → "notify session"). Falls back to raw request JSON so the terminal pane is never blank. - state::* : new family view for get/set/delete/update/list/list_groups — scope/key chips + the unwrapped result as one highlighted JSON block. - Tests (engine + state parsers) and Storybook fixtures (EngineFamily, new StateFamily gallery). Claude-Session: https://claude.ai/code/session_01SB8sknFhJLojcaBdQazmPH --- .../chat/FunctionCallMessage.stories.tsx | 6 + .../components/chat/FunctionCallMessage.tsx | 12 +- .../chat/engine/RegisterTriggerView.tsx | 208 ++++++++++++++++++ .../chat/engine/__tests__/parsers.test.ts | 79 +++++++ .../web/src/components/chat/engine/index.tsx | 5 + .../web/src/components/chat/engine/parsers.ts | 76 +++++++ .../src/components/chat/state/StateView.tsx | 59 +++++ .../chat/state/__tests__/parsers.test.ts | 71 ++++++ .../web/src/components/chat/state/index.tsx | 61 +++++ .../web/src/components/chat/state/parsers.ts | 32 +++ .../src/stories/fixtures/engine-fixtures.ts | 68 ++++++ .../src/stories/fixtures/state-fixtures.ts | 81 +++++++ 12 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 console/web/src/components/chat/engine/RegisterTriggerView.tsx create mode 100644 console/web/src/components/chat/state/StateView.tsx create mode 100644 console/web/src/components/chat/state/__tests__/parsers.test.ts create mode 100644 console/web/src/components/chat/state/index.tsx create mode 100644 console/web/src/components/chat/state/parsers.ts create mode 100644 console/web/src/stories/fixtures/state-fixtures.ts diff --git a/console/web/src/components/chat/FunctionCallMessage.stories.tsx b/console/web/src/components/chat/FunctionCallMessage.stories.tsx index 565553b52..5f64dc683 100644 --- a/console/web/src/components/chat/FunctionCallMessage.stories.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.stories.tsx @@ -6,6 +6,7 @@ import { harnessFixtures } from '@/stories/fixtures/harness-fixtures' import { routerFixtures } from '@/stories/fixtures/router-fixtures' import { sandboxFixtures } from '@/stories/fixtures/sandbox-fixtures' import { shellFixtures } from '@/stories/fixtures/shell-fixtures' +import { stateFixtures } from '@/stories/fixtures/state-fixtures' import { webFixtures } from '@/stories/fixtures/web-fixtures' import { workerFixtures } from '@/stories/fixtures/worker-fixtures' import { workflowFixtures } from '@/stories/fixtures/workflow-fixtures' @@ -173,3 +174,8 @@ export const HarnessFamily: Story = { name: 'harness family', render: () => , } + +export const StateFamily: Story = { + name: 'state family', + render: () => , +} diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx index 65ac93bb9..33590c596 100644 --- a/console/web/src/components/chat/FunctionCallMessage.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -15,6 +15,7 @@ import { SandboxToolView, } from '@/components/chat/sandbox' import { ShellFunctionIdLabel, ShellToolView } from '@/components/chat/shell' +import { StateFunctionIdLabel, StateToolView } from '@/components/chat/state' import { WebFunctionIdLabel, WebToolView } from '@/components/chat/web' import { WorkerFunctionIdLabel, WorkerToolView } from '@/components/chat/worker' import { @@ -140,6 +141,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) { if (HarnessToolView.isHarnessFunction(functionId)) { return } + if (StateToolView.isStateFunction(functionId)) { + return + } return {functionId} } @@ -170,7 +174,8 @@ export function FunctionCallMessage({ ShellToolView.tryRenderPreview(message) ?? WorkflowToolView.tryRenderPreview(message) ?? RouterToolView.tryRenderPreview(message) ?? - HarnessToolView.tryRenderPreview(message) + HarnessToolView.tryRenderPreview(message) ?? + StateToolView.tryRenderPreview(message) const customTerminal = !pending ? (SandboxToolView.tryRender(message) ?? EngineToolView.tryRender(message) ?? @@ -181,13 +186,14 @@ export function FunctionCallMessage({ ShellToolView.tryRender(message) ?? WorkflowToolView.tryRender(message) ?? RouterToolView.tryRender(message) ?? - HarnessToolView.tryRender(message)) + HarnessToolView.tryRender(message) ?? + StateToolView.tryRender(message)) : null const hasCustomTerminal = customTerminal != null const showRequestPaneAbove = !(pending && customPreview) && !(running && hasCustomTerminal) && - !(!pending && !running && hasCustomTerminal) + !(!pending && !running) const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => { const handler = diff --git a/console/web/src/components/chat/engine/RegisterTriggerView.tsx b/console/web/src/components/chat/engine/RegisterTriggerView.tsx new file mode 100644 index 000000000..00c37f2cc --- /dev/null +++ b/console/web/src/components/chat/engine/RegisterTriggerView.tsx @@ -0,0 +1,208 @@ +import type { ReactNode } from 'react' +import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { + type ReactOptions, + type ReactSpec, + type RegisterTriggerRequest, + type RegisterTriggerResponse, + reactOptionsSchema, + reactSpecSchema, + registerTriggerRequestSchema, + registerTriggerResponseSchema, + type StateTriggerConfig, + safeParseRequest, + safeParseResponse, + stateTriggerConfigSchema, +} from './parsers' +import { FilterChip } from './shared' + +interface RegisterTriggerViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function RegisterTriggerView({ + input, + output, + running, +}: RegisterTriggerViewProps) { + const req = safeParseRequest( + registerTriggerRequestSchema, + input, + ) + // Never render blank: an unrecognized payload falls back to raw JSON rather + // than an empty terminal pane (the switch always mounts this component). + if (!req) return + + const stateCfg = + req.trigger_type === 'state' + ? safeParseRequest( + stateTriggerConfigSchema, + req.config, + ) + : null + const react = + req.function_id === 'harness::react' + ? safeParseRequest(reactSpecSchema, req.metadata) + : null + const allow = react + ? safeParseRequest(reactOptionsSchema, react.options) + ?.functions?.allow + : undefined + + const resp = running + ? null + : safeParseResponse( + registerTriggerResponseSchema, + output, + ) + const regId = resp?.id ?? resp?.subscription_id + const once = resp?.once ?? req.once + + const hasStateChips = + !!stateCfg && + (!!stateCfg.scope || !!stateCfg.key || !!stateCfg.condition_function_id) + + return ( +
+ + + {req.label ? : null} + {typeof once === 'boolean' ? ( + + ) : null} + {regId ? ( + + + id + + + {shortenId(regId)} + + + ) : null} + + +
+ + {req.trigger_type} + + + {req.function_id ? ( + + {req.function_id} + + ) : ( + + notify session + + )} +
+ + {hasStateChips ? ( +
+ {stateCfg?.scope ? ( + + ) : null} + {stateCfg?.key ? ( + + ) : null} + {stateCfg?.condition_function_id ? ( + + ) : null} +
+ ) : req.config !== undefined && !isEmpty(req.config) ? ( + + ) : null} + + {react ? ( + <> +
+ + {allow?.length + ? allow.map((fn) => ( + + {fn} + + )) + : null} +
+ {react.join ? ( +
+ + join + + {react.join.id} + · + + key {react.join.key} + + · + + expect{' '} + + [{react.join.expect.join(', ')}] + + + {react.join.rearm ? ( + <> + · + rearm + + ) : null} +
+ ) : null} + + + ) : req.metadata !== undefined ? ( + + ) : null} +
+ ) +} + +function isEmpty(v: unknown): boolean { + if (v === null || v === undefined) return true + if (typeof v === 'object') { + return Object.keys(v as Record).length === 0 + } + return false +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} + +function PaneLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function LabeledJson({ label, value }: { label: string; value: unknown }) { + return ( +
+ {label} + +
+ ) +} + +function LabeledText({ label, text }: { label: string; text: string }) { + return ( +
+ {label} +
+        {text}
+      
+
+ ) +} diff --git a/console/web/src/components/chat/engine/__tests__/parsers.test.ts b/console/web/src/components/chat/engine/__tests__/parsers.test.ts index e15c916ec..84379f7f6 100644 --- a/console/web/src/components/chat/engine/__tests__/parsers.test.ts +++ b/console/web/src/components/chat/engine/__tests__/parsers.test.ts @@ -6,8 +6,11 @@ import { functionsListRequestSchema, functionsListResponseSchema, isEngineListFunction, + reactSpecSchema, registeredTriggersListRequestSchema, registeredTriggersListResponseSchema, + registerTriggerRequestSchema, + registerTriggerResponseSchema, safeParseRequest, safeParseResponse, triggersListRequestSchema, @@ -361,6 +364,82 @@ describe('engine::workers::register', () => { }) }) +describe('engine::register_trigger', () => { + it('is included in the engine function id set', () => { + expect(ENGINE_FUNCTION_IDS).toContain('engine::register_trigger') + expect(isEngineListFunction('engine::register_trigger')).toBe(true) + }) + + it('parses a state-trigger → harness::react registration', () => { + const req = safeParseRequest(registerTriggerRequestSchema, { + trigger_type: 'state', + function_id: 'harness::react', + config: { key: 'build', scope: 'ops' }, + metadata: { + model: 'claude-sonnet-5', + task: 'You are the GATE REVIEWER', + options: { functions: { allow: ['state::get'] } }, + join: { + id: 'gate-decision-join', + key: 'build', + expect: ['build', 'tests'], + rearm: true, + }, + }, + }) + expect(req?.trigger_type).toBe('state') + expect(req?.function_id).toBe('harness::react') + const react = safeParseRequest(reactSpecSchema, req?.metadata) + expect(react?.model).toBe('claude-sonnet-5') + expect(react?.join?.expect).toEqual(['build', 'tests']) + expect(react?.join?.rearm).toBe(true) + }) + + it('rejects a react spec whose join.expect is a count, not an array', () => { + expect( + safeParseRequest(reactSpecSchema, { + model: 'm', + task: 't', + join: { id: 'j', key: 'build', expect: 2 }, + }), + ).toBeNull() + }) + + it('parses the harness subscribe variant (no function_id, has label/once)', () => { + const req = safeParseRequest(registerTriggerRequestSchema, { + trigger_type: 'state', + config: { key: 'progress', scope: 'research' }, + label: 'research-progress-watch', + once: false, + }) + expect(req?.trigger_type).toBe('state') + expect(req?.function_id).toBeUndefined() + expect(req?.label).toBe('research-progress-watch') + expect(req?.once).toBe(false) + }) + + it('rejects a request missing the required trigger_type', () => { + expect( + safeParseRequest(registerTriggerRequestSchema, { config: {} }), + ).toBeNull() + }) + + it('parses the engine response { id }', () => { + expect( + safeParseResponse(registerTriggerResponseSchema, wrap({ id: 'trg-1' })), + ).toEqual({ id: 'trg-1' }) + }) + + it('parses the harness-intercepted response { subscription_id, once }', () => { + expect( + safeParseResponse(registerTriggerResponseSchema, { + subscription_id: 'sub-1', + once: false, + }), + ).toEqual({ subscription_id: 'sub-1', once: false }) + }) +}) + describe('unwrapEnvelope re-export', () => { it('peels the harness envelope', () => { const inner = { functions: [] } diff --git a/console/web/src/components/chat/engine/index.tsx b/console/web/src/components/chat/engine/index.tsx index 999162126..cf836456a 100644 --- a/console/web/src/components/chat/engine/index.tsx +++ b/console/web/src/components/chat/engine/index.tsx @@ -5,6 +5,7 @@ import { FunctionInfoView } from './FunctionInfoView' import { FunctionsListView } from './FunctionsListView' import { isEngineListFunction, unwrapEnvelope } from './parsers' import { RegisteredTriggersListView } from './RegisteredTriggersListView' +import { RegisterTriggerView } from './RegisterTriggerView' import { TriggersListView } from './TriggersListView' import { WorkerInfoView } from './WorkerInfoView' import { WorkersListView } from './WorkersListView' @@ -73,6 +74,10 @@ function tryRender(message: FunctionCallMessage): React.ReactNode | null { return ( ) + case 'engine::register_trigger': + return ( + + ) default: return null } diff --git a/console/web/src/components/chat/engine/parsers.ts b/console/web/src/components/chat/engine/parsers.ts index 233096362..ddf052ef3 100644 --- a/console/web/src/components/chat/engine/parsers.ts +++ b/console/web/src/components/chat/engine/parsers.ts @@ -23,6 +23,7 @@ export const ENGINE_FUNCTION_IDS = [ 'engine::workers::list', 'engine::workers::info', 'engine::workers::register', + 'engine::register_trigger', ] as const export type EngineFunctionId = (typeof ENGINE_FUNCTION_IDS)[number] @@ -241,6 +242,81 @@ export type WorkersRegisterResponse = z.infer< typeof workersRegisterResponseSchema > +/* ---------------- engine::register_trigger ---------------- */ + +/** + * Registration request. Covers both wire shapes seen under this id: + * - engine `RegisterTriggerInput` (iii-sdk `protocol.rs`): `{ trigger_type, + * function_id, config, metadata? }`. + * - harness `SubscribeArgs` (`harness/src/functions/subscribe.rs`): + * `{ trigger_type, config?, label?, once?, function_id?, metadata? }` — + * `function_id` omitted means "notify this session". + * Only `trigger_type` is guaranteed; everything else is optional so the view + * always renders. `config`/`metadata` are opaque JSON parsed per-provider. + */ +export const registerTriggerRequestSchema = z.object({ + trigger_type: z.string(), + function_id: z.string().optional(), + config: z.unknown().optional(), + metadata: z.unknown().optional(), + label: z.string().optional(), + once: z.boolean().optional(), +}) +export type RegisterTriggerRequest = z.infer< + typeof registerTriggerRequestSchema +> + +/** `config` shape for `trigger_type: "state"` (all fields optional filters). */ +export const stateTriggerConfigSchema = z.object({ + scope: z.string().optional(), + key: z.string().optional(), + condition_function_id: z.string().optional(), +}) +export type StateTriggerConfig = z.infer + +/** + * `metadata` shape for `function_id: "harness::react"` — the reactive bridge. + * Wire source: `harness/src/functions/react.rs` (`ReactSpec` / `JoinSpec`). + * `options` is free-form (mirrors `harness::spawn` SpawnOptions); the common + * `options.functions.allow: string[]` is surfaced by the view. + */ +export const joinSpecSchema = z.object({ + id: z.string(), + expect: z.array(z.string()), + key: z.string(), + rearm: z.boolean().optional(), +}) +export type JoinSpec = z.infer + +export const reactSpecSchema = z.object({ + model: z.string(), + task: z.string(), + session_id: z.string().optional(), + provider: z.string().optional(), + options: z.unknown().optional(), + parent_session_id: z.string().optional(), + join: joinSpecSchema.optional(), +}) +export type ReactSpec = z.infer + +/** `options.functions.allow` — the only bit of the free-form `options` the + * view reads. Non-strict so unknown option keys pass through. */ +export const reactOptionsSchema = z.object({ + functions: z.object({ allow: z.array(z.string()).optional() }).optional(), +}) +export type ReactOptions = z.infer + +/** Engine returns `{ id }`; the harness-intercepted path returns + * `{ subscription_id, once }`. Model both loosely. */ +export const registerTriggerResponseSchema = z.object({ + id: z.string().optional(), + subscription_id: z.string().optional(), + once: z.boolean().optional(), +}) +export type RegisterTriggerResponse = z.infer< + typeof registerTriggerResponseSchema +> + /* ---------------- generic helpers ---------------- */ export function safeParseRequest( diff --git a/console/web/src/components/chat/state/StateView.tsx b/console/web/src/components/chat/state/StateView.tsx new file mode 100644 index 000000000..7a8860458 --- /dev/null +++ b/console/web/src/components/chat/state/StateView.tsx @@ -0,0 +1,59 @@ +import { FilterChip } from '@/components/chat/engine/shared' +import { MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { safeParseRequest, stateRequestSchema, unwrapEnvelope } from './parsers' + +interface StateViewProps { + functionId: string + input: unknown + output: unknown + running?: boolean +} + +/** `null`, `undefined`, `""`, `[]`, `{}` render as a compact "empty" note + * rather than a noisy JSON block. Mirrors `ValuePane`'s `isEmptyValue`. */ +function isEmptyValue(v: unknown): boolean { + if (v === null || v === undefined) return true + if (typeof v === 'string') return v.length === 0 + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'object') { + return Object.keys(v as Record).length === 0 + } + return false +} + +export function StateView({ + functionId, + input, + output, + running, +}: StateViewProps) { + const req = safeParseRequest(stateRequestSchema, input) + const op = functionId.startsWith('state::') + ? functionId.slice('state::'.length) + : functionId + + const value = running ? undefined : unwrapEnvelope(output) + const empty = !running && isEmptyValue(value) + + return ( +
+ + + {req?.scope ? : null} + {req?.key ? : null} + + {running ? ( +
+ · running… +
+ ) : empty ? ( +
+ · empty +
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/state/__tests__/parsers.test.ts b/console/web/src/components/chat/state/__tests__/parsers.test.ts new file mode 100644 index 000000000..796c49e80 --- /dev/null +++ b/console/web/src/components/chat/state/__tests__/parsers.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + isStateFunction, + safeParseRequest, + stateRequestSchema, + unwrapEnvelope, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isStateFunction', () => { + it('matches every state:: op via prefix', () => { + for (const id of [ + 'state::get', + 'state::set', + 'state::delete', + 'state::update', + 'state::list', + 'state::list_groups', + ]) { + expect(isStateFunction(id)).toBe(true) + } + }) + + it('rejects non-state ids', () => { + expect(isStateFunction('stateful::x')).toBe(false) + expect(isStateFunction('engine::register_trigger')).toBe(false) + expect(isStateFunction('state')).toBe(false) + }) +}) + +describe('stateRequestSchema', () => { + it('parses scope + key', () => { + expect( + safeParseRequest(stateRequestSchema, { scope: 'ops', key: 'build' }), + ).toEqual({ scope: 'ops', key: 'build' }) + }) + + it('tolerates missing fields (list_groups)', () => { + expect(safeParseRequest(stateRequestSchema, {})).toEqual({}) + expect(safeParseRequest(stateRequestSchema, undefined)).toEqual({}) + }) + + it('ignores extra request fields (value / ops)', () => { + expect( + safeParseRequest(stateRequestSchema, { + scope: 'ops', + key: 'build', + value: { status: 'green' }, + }), + ).toEqual({ scope: 'ops', key: 'build' }) + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope to the stored value', () => { + const value = { commit: 'abc123', status: 'green' } + expect(unwrapEnvelope(wrap(value))).toEqual(value) + }) + + it('returns primitives unchanged', () => { + expect(unwrapEnvelope(null)).toBeNull() + expect(unwrapEnvelope(42)).toBe(42) + }) +}) diff --git a/console/web/src/components/chat/state/index.tsx b/console/web/src/components/chat/state/index.tsx new file mode 100644 index 000000000..435a3f600 --- /dev/null +++ b/console/web/src/components/chat/state/index.tsx @@ -0,0 +1,61 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { isStateFunction } from './parsers' +import { StateView } from './StateView' + +/** + * Header label for `state::*` ids — dims the namespace prefix so the op + * (`get`, `set`, …) reads clearly. Mirrors `EngineFunctionIdLabel`. + */ +export function StateFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('state::')) { + return {functionId} + } + const tail = functionId.slice('state::'.length) + return ( + <> + state:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isStateFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const running = !!message.running + const rawOutput = message.output + + // Reuse the shared error parser for gate/transport-level errors — the + // `function_error` envelope is shared infra, not sandbox-specific. + const errorDisplay = + !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + return ( + + ) +} + +/** `state::*` calls have no bespoke pending preview. */ +function tryRenderPreview( + _message: FunctionCallMessage, +): React.ReactNode | null { + return null +} + +export const StateToolView = { + isStateFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/state/parsers.ts b/console/web/src/components/chat/state/parsers.ts new file mode 100644 index 000000000..43ca85651 --- /dev/null +++ b/console/web/src/components/chat/state/parsers.ts @@ -0,0 +1,32 @@ +/** + * Parsers for the `state::*` family (`state::get|set|delete|update|list| + * list_groups`). All ops are keyed by `scope` (+ `key`, except `list` / + * `list_groups`); stored values are arbitrary JSON. The minimal view only + * surfaces `scope`/`key` from the request and renders the unwrapped result + * as JSON, so the request schema stays deliberately loose. + * + * Wire source: engine state worker (`state::*` are engine built-ins). + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +/** Every `state::` id is handled by this family (prefix match). */ +export function isStateFunction(id: string): boolean { + return id.startsWith('state::') +} + +export const stateRequestSchema = z.object({ + scope: z.string().optional(), + key: z.string().optional(), +}) +export type StateRequest = z.infer + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/stories/fixtures/engine-fixtures.ts b/console/web/src/stories/fixtures/engine-fixtures.ts index 7cb26f54f..de16839ce 100644 --- a/console/web/src/stories/fixtures/engine-fixtures.ts +++ b/console/web/src/stories/fixtures/engine-fixtures.ts @@ -559,6 +559,70 @@ export const engineWorkerRegisterRunning = base( { running: true }, ) +/* ---------------- engine::register_trigger ---------------- */ + +export const engineRegisterTriggerReact = base( + 'engine-register-trigger-react', + 'engine::register_trigger', + { + trigger_type: 'state', + function_id: 'harness::react', + config: { key: 'build', scope: 'ops' }, + metadata: { + model: 'claude-sonnet-5', + session_id: 'console-f0aac029-62a3-43f8-953b-45d0d903a866', + task: 'You are the GATE REVIEWER for a deploy pipeline. Both state records ops/build and ops/tests must be green before you approve.', + options: { functions: { allow: ['state::get'] } }, + join: { + id: 'gate-decision-join', + key: 'build', + expect: ['build', 'tests'], + rearm: true, + }, + }, + }, + wrapHarness({ id: 'trg-abc123def456' }), +) + +export const engineRegisterTriggerCron = base( + 'engine-register-trigger-cron', + 'engine::register_trigger', + { + trigger_type: 'cron', + function_id: 'ops::nightly-sweep', + config: { expression: '0 0 3 * * *' }, + }, + wrapHarness({ id: 'trg-cron-01' }), +) + +export const engineRegisterTriggerSubscribe = base( + 'engine-register-trigger-subscribe', + 'engine::register_trigger', + { + trigger_type: 'state', + config: { key: 'progress', scope: 'research' }, + label: 'research-progress-watch', + once: false, + }, + wrapHarness({ + once: false, + subscription_id: 'sub_6c9c9f043f5f449dab6569d2f27a8c05', + }), +) + +export const engineRegisterTriggerRunning = base( + 'engine-register-trigger-running', + 'engine::register_trigger', + { + trigger_type: 'state', + function_id: 'harness::react', + config: { scope: 'ops' }, + metadata: { model: 'claude-sonnet-5', task: 'watch for changes' }, + }, + undefined, + { running: true }, +) + export const engineFixtures = [ engineFunctionsListDone, engineFunctionsListRaw, @@ -578,6 +642,10 @@ export const engineFixtures = [ engineWorkerInfoNotFound, engineWorkerRegisterDone, engineWorkerRegisterRunning, + engineRegisterTriggerReact, + engineRegisterTriggerCron, + engineRegisterTriggerSubscribe, + engineRegisterTriggerRunning, engineRunning, engineFunctionsListGateError, ] as const diff --git a/console/web/src/stories/fixtures/state-fixtures.ts b/console/web/src/stories/fixtures/state-fixtures.ts new file mode 100644 index 000000000..9c0d0342d --- /dev/null +++ b/console/web/src/stories/fixtures/state-fixtures.ts @@ -0,0 +1,81 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 2, + createdAt: now, + ...extra, + } +} + +/* ---------------- state::get ---------------- */ + +export const stateGetValue = base( + 'state-get-value', + 'state::get', + { scope: 'ops', key: 'build' }, + wrapHarness({ commit: 'abc123', status: 'green' }), +) + +export const stateGetMissing = base( + 'state-get-missing', + 'state::get', + { scope: 'ops', key: 'nonexistent' }, + wrapHarness(null), +) + +export const stateGetRunning = base( + 'state-get-running', + 'state::get', + { scope: 'ops', key: 'build' }, + undefined, + { running: true }, +) + +/* ---------------- state::set / delete ---------------- */ + +export const stateSet = base( + 'state-set', + 'state::set', + { scope: 'ops', key: 'build', value: { status: 'green' } }, + wrapHarness({ old_value: { status: 'red' }, new_value: { status: 'green' } }), +) + +export const stateDelete = base( + 'state-delete', + 'state::delete', + { scope: 'ops', key: 'build' }, + wrapHarness({ status: 'green' }), +) + +/* ---------------- state::list_groups ---------------- */ + +export const stateListGroups = base( + 'state-list-groups', + 'state::list_groups', + {}, + wrapHarness({ groups: ['ops', 'build', 'sessions'] }), +) + +export const stateFixtures = [ + stateGetValue, + stateGetMissing, + stateGetRunning, + stateSet, + stateDelete, + stateListGroups, +] as const From 8e76e77d784fdf8d91de96c86490782ba2e45a75 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 2 Jul 2026 16:59:44 -0300 Subject: [PATCH 2/2] fix(console): guard state view error detection against denial-shaped values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state::* results are arbitrary JSON, so running every result through the shared sandbox error parser flagged a successful get of a value like { status: "denied" } / { denied_by: … } as a red "Denied" error, hiding the real JSON on the default tab. Only run the parser for non-success envelopes; genuine errors ({ error: { kind: "function_error" } }, wire/ denial shapes) still surface since they are never { content, details }. Also dedupe the harness::react allow-list chips so the React key stays unique when the engine-supplied allow list repeats a function id. Adds state view render tests covering both directions. --- .../chat/engine/RegisterTriggerView.tsx | 2 +- .../chat/state/__tests__/view.test.tsx | 58 +++++++++++++++++++ .../web/src/components/chat/state/index.tsx | 18 +++++- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 console/web/src/components/chat/state/__tests__/view.test.tsx diff --git a/console/web/src/components/chat/engine/RegisterTriggerView.tsx b/console/web/src/components/chat/engine/RegisterTriggerView.tsx index 00c37f2cc..6c6c026d8 100644 --- a/console/web/src/components/chat/engine/RegisterTriggerView.tsx +++ b/console/web/src/components/chat/engine/RegisterTriggerView.tsx @@ -125,7 +125,7 @@ export function RegisterTriggerView({
{allow?.length - ? allow.map((fn) => ( + ? Array.from(new Set(allow)).map((fn) => ( {fn} diff --git a/console/web/src/components/chat/state/__tests__/view.test.tsx b/console/web/src/components/chat/state/__tests__/view.test.tsx new file mode 100644 index 000000000..29ebf1e30 --- /dev/null +++ b/console/web/src/components/chat/state/__tests__/view.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import type { FunctionCallMessage } from '@/types/chat' +import { StateToolView } from '../index' +import { StateView } from '../StateView' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +function msg(output: unknown): FunctionCallMessage { + return { + id: 'state-view-test', + role: 'function-call', + functionId: 'state::get', + input: { scope: 'ops', key: 'gate' }, + output, + durationMs: 1, + createdAt: 0, + } +} + +/** Regression: state values are arbitrary JSON, so a *successful* get whose + * stored value merely looks like a denial (`{ status: 'denied' }`) must render + * as the value view — not the red error view — while a genuine + * `function_error` envelope still surfaces as the error view. */ +describe('StateToolView.tryRender error discrimination', () => { + it('renders a denial-shaped stored value as the value view, not an error', () => { + const node = StateToolView.tryRender( + msg(wrap({ status: 'denied', reason: 'gate closed' })), + ) + expect(node).not.toBeNull() + expect((node as { type: unknown }).type).toBe(StateView) + }) + + it('renders a { denied_by } stored value as the value view, not an error', () => { + const node = StateToolView.tryRender(msg(wrap({ denied_by: 'user' }))) + expect((node as { type: unknown }).type).toBe(StateView) + }) + + it('still renders a real function_error envelope as the error view', () => { + const node = StateToolView.tryRender( + msg({ + error: { + kind: 'function_error', + message: 'boom', + details: { status: 'denied', denied_by: 'gate_unavailable' }, + }, + }), + ) + expect(node).not.toBeNull() + expect((node as { type: unknown }).type).toBe(SandboxErrorView) + }) +}) diff --git a/console/web/src/components/chat/state/index.tsx b/console/web/src/components/chat/state/index.tsx index 435a3f600..bd6768621 100644 --- a/console/web/src/components/chat/state/index.tsx +++ b/console/web/src/components/chat/state/index.tsx @@ -29,9 +29,23 @@ function tryRender(message: FunctionCallMessage): React.ReactNode | null { const rawOutput = message.output // Reuse the shared error parser for gate/transport-level errors — the - // `function_error` envelope is shared infra, not sandbox-specific. + // `function_error` envelope is shared infra, not sandbox-specific. But state + // values are arbitrary JSON, and a *successful* result whose stored value + // looks like a denial (e.g. `{ status: 'denied' }`, `{ denied_by: … }`) would + // otherwise be misread as an error. A real error is `{ error: { kind: + // 'function_error', … } }`; a success is a `{ content, details }` harness + // envelope. Skip the parser for success envelopes so only genuine errors — + // which are never `{ content, details }` shaped — reach it. + const isSuccessEnvelope = + !!rawOutput && + typeof rawOutput === 'object' && + !Array.isArray(rawOutput) && + Array.isArray((rawOutput as Record).content) && + 'details' in (rawOutput as Record) const errorDisplay = - !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + !running && rawOutput != null && !isSuccessEnvelope + ? parseSandboxErrorDisplay(rawOutput) + : null if (errorDisplay) { return }