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 @@ -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'
Expand Down Expand Up @@ -173,3 +174,8 @@ export const HarnessFamily: Story = {
name: 'harness family',
render: () => <FamilyGallery fixtures={harnessFixtures} />,
}

export const StateFamily: Story = {
name: 'state family',
render: () => <FamilyGallery fixtures={stateFixtures} />,
}
12 changes: 9 additions & 3 deletions console/web/src/components/chat/FunctionCallMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -140,6 +141,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) {
if (HarnessToolView.isHarnessFunction(functionId)) {
return <HarnessFunctionIdLabel functionId={functionId} />
}
if (StateToolView.isStateFunction(functionId)) {
return <StateFunctionIdLabel functionId={functionId} />
}
return <span className="text-ink">{functionId}</span>
}

Expand Down Expand Up @@ -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) ??
Expand All @@ -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 =
Expand Down
208 changes: 208 additions & 0 deletions console/web/src/components/chat/engine/RegisterTriggerView.tsx
Original file line number Diff line number Diff line change
@@ -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<RegisterTriggerRequest>(
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 <LabeledJson label="request" value={input} />

const stateCfg =
req.trigger_type === 'state'
? safeParseRequest<StateTriggerConfig>(
stateTriggerConfigSchema,
req.config,
)
: null
const react =
req.function_id === 'harness::react'
? safeParseRequest<ReactSpec>(reactSpecSchema, req.metadata)
: null
const allow = react
? safeParseRequest<ReactOptions>(reactOptionsSchema, react.options)
?.functions?.allow
: undefined

const resp = running
? null
: safeParseResponse<RegisterTriggerResponse>(
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 (
<div className="border-t border-rule-2 bg-bg">
<MetaRow>
<StatusPill
label={running ? 'registering trigger…' : 'trigger registered'}
variant={running ? 'default' : 'accent'}
/>
{req.label ? <FilterChip label="label" value={req.label} /> : null}
{typeof once === 'boolean' ? (
<FilterChip label="mode" value={once ? 'one-shot' : 'persistent'} />
) : null}
{regId ? (
<Chip>
<span className="text-ink-faint uppercase tracking-[0.06em]">
id
</span>
<span className="ml-1 text-ink" title={regId}>
{shortenId(regId)}
</span>
</Chip>
) : null}
</MetaRow>

<div className="px-3 py-2 border-b border-rule-2 bg-bg flex items-baseline gap-2 flex-wrap">
<span className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint">
{req.trigger_type}
</span>
<span className="font-mono text-[11px] text-ink-faint">→</span>
{req.function_id ? (
<span className="font-mono text-[12.5px] text-accent break-all">
{req.function_id}
</span>
) : (
<span className="font-mono text-[12.5px] text-ink-faint italic">
notify session
</span>
)}
</div>

{hasStateChips ? (
<div className="px-3 py-1.5 border-b border-rule-2 bg-paper-2 flex flex-wrap items-center gap-1.5">
{stateCfg?.scope ? (
<FilterChip label="scope" value={stateCfg.scope} />
) : null}
{stateCfg?.key ? (
<FilterChip label="key" value={stateCfg.key} />
) : null}
{stateCfg?.condition_function_id ? (
<FilterChip label="if" value={stateCfg.condition_function_id} />
) : null}
</div>
) : req.config !== undefined && !isEmpty(req.config) ? (
<LabeledJson label="config" value={req.config} />
) : null}

{react ? (
<>
<div className="px-3 py-1.5 border-b border-rule-2 bg-paper-2 flex flex-wrap items-center gap-1.5">
<FilterChip label="model" value={react.model} />
{allow?.length
? Array.from(new Set(allow)).map((fn) => (
<Chip key={fn}>
<span className="text-ink">{fn}</span>
</Chip>
))
: null}
</div>
{react.join ? (
<div className="px-3 py-2 border-b border-rule-2 bg-bg font-mono text-[12px] text-ink flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-ink-faint uppercase tracking-[0.06em] text-[10px]">
join
</span>
<span className="text-accent break-all">{react.join.id}</span>
<span className="text-ink-ghost">·</span>
<span>
key <span className="text-ink-faint">{react.join.key}</span>
</span>
<span className="text-ink-ghost">·</span>
<span>
expect{' '}
<span className="text-ink-faint">
[{react.join.expect.join(', ')}]
</span>
</span>
{react.join.rearm ? (
<>
<span className="text-ink-ghost">·</span>
<span className="text-accent">rearm</span>
</>
) : null}
</div>
) : null}
<LabeledText label="task" text={react.task} />
</>
) : req.metadata !== undefined ? (
<LabeledJson label="metadata" value={req.metadata} />
) : null}
</div>
)
}

function isEmpty(v: unknown): boolean {
if (v === null || v === undefined) return true
if (typeof v === 'object') {
return Object.keys(v as Record<string, unknown>).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 (
<div className="bg-paper-2 px-3 py-1.5 border-b border-rule-2 font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint">
{children}
</div>
)
}

function LabeledJson({ label, value }: { label: string; value: unknown }) {
return (
<div>
<PaneLabel>{label}</PaneLabel>
<JsonHighlight code={JSON.stringify(value ?? null, null, 2)} />
</div>
)
}

function LabeledText({ label, text }: { label: string; text: string }) {
return (
<div>
<PaneLabel>{label}</PaneLabel>
<pre className="bg-bg overflow-x-auto px-3 py-2 font-mono text-[12.5px] leading-[1.55] text-ink whitespace-pre-wrap break-words">
<code>{text}</code>
</pre>
</div>
)
}
79 changes: 79 additions & 0 deletions console/web/src/components/chat/engine/__tests__/parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
functionsListRequestSchema,
functionsListResponseSchema,
isEngineListFunction,
reactSpecSchema,
registeredTriggersListRequestSchema,
registeredTriggersListResponseSchema,
registerTriggerRequestSchema,
registerTriggerResponseSchema,
safeParseRequest,
safeParseResponse,
triggersListRequestSchema,
Expand Down Expand Up @@ -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: [] }
Expand Down
5 changes: 5 additions & 0 deletions console/web/src/components/chat/engine/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -73,6 +74,10 @@ function tryRender(message: FunctionCallMessage): React.ReactNode | null {
return (
<WorkersRegisterView input={input} output={output} running={running} />
)
case 'engine::register_trigger':
return (
<RegisterTriggerView input={input} output={output} running={running} />
)
default:
return null
}
Expand Down
Loading
Loading