diff --git a/apps/agent-forge/server/__tests__/channelTriggers.test.ts b/apps/agent-forge/server/__tests__/channelTriggers.test.ts index 1c717c82f..53b712dab 100644 --- a/apps/agent-forge/server/__tests__/channelTriggers.test.ts +++ b/apps/agent-forge/server/__tests__/channelTriggers.test.ts @@ -85,13 +85,14 @@ interface Harness { host: { runsOf: ReturnType>; cancel: ReturnType>; + isEnabled: ReturnType>; }; baseDir: string; } async function harness( spec: AgentSpecV2, - options: { secrets?: Record; agent?: SimpleAgent; runs?: HostRun[] } = {} + options: { secrets?: Record; agent?: SimpleAgent; runs?: HostRun[]; enabled?: boolean } = {} ): Promise { const baseDir = tmpDir(); const repo = new AgentRepo(baseDir); @@ -103,6 +104,7 @@ async function harness( const host = { runsOf: vi.fn<[agentId: string], HostRun[]>(() => options.runs ?? []), cancel: vi.fn<[runKey: string, reason: string], 'queued' | 'running' | undefined>(() => 'queued'), + isEnabled: vi.fn<[agentId: string], boolean>(() => options.enabled ?? true), }; const channels = new ChannelTriggers({ host: host as unknown as TriggerHost, @@ -218,6 +220,23 @@ describe('ChannelTriggers.mount / status', () => { ]); }); + it('answers 200 skipped disabled for a turned-off agent and never calls the SDK handler (R09)', async () => { + const agent = createAgent({ provider: mockModel(['Pong']) }); + const { app } = await harness(specWith([HTTP_TRIGGER]), { + agent: agent as unknown as SimpleAgent, + enabled: false, + }); + + const res = await request(app) + .post(`/hooks/${AGENT}/channel/http`) + .set('content-type', 'application/json') + .send({ sessionKey: 'u1', input: 'ping' }); + + expect(res.status).toBe(200); + // The SDK handler's `{ text }` reply never happens - platforms see the skip. + expect(res.body).toEqual({ ok: true, skipped: 'disabled' }); + }); + it('answers 404-shaped fallthrough for a channel path with no mounted trigger', async () => { const { app } = await harness(specWith([HTTP_TRIGGER])); const res = await request(app).post(`/hooks/${AGENT}/channel/telegram`).send('{}'); @@ -414,7 +433,7 @@ describe('channel route inside createApp', () => { const repo = new AgentRepo(baseDir); await repo.writeSpec(AGENT, specWith([HTTP_TRIGGER])); const channels = new ChannelTriggers({ - host: { runsOf: () => [], cancel: () => undefined } as unknown as TriggerHost, + host: { runsOf: () => [], cancel: () => undefined, isEnabled: () => true } as unknown as TriggerHost, repo, buildAgent: async () => createAgent({ provider: mockModel(['ViaApp']) }) as unknown as SimpleAgent, resolve: resolver({}), diff --git a/apps/agent-forge/server/__tests__/cronTriggers.test.ts b/apps/agent-forge/server/__tests__/cronTriggers.test.ts index 607ee88ff..08ca2d6be 100644 --- a/apps/agent-forge/server/__tests__/cronTriggers.test.ts +++ b/apps/agent-forge/server/__tests__/cronTriggers.test.ts @@ -173,6 +173,23 @@ describe('CronTriggers', () => { expect(log).toHaveBeenCalledWith('a', 'Cron trigger t1 fired'); }); + it('skips a disabled agent, logs the contract line and still advances the cursor (R09)', async () => { + const { cron, dispatch, log } = make({ a: [CRON] }, { ok: false, reason: 'disabled' }); + await cron.tick(); + now = new Date('2026-01-01T10:05:00.000Z'); + await cron.tick(); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('a', 'Cron trigger t1 skipped: the agent is turned off', 'warn'); + // The claim moved the cursor before the dispatch, so re-enabling later + // never bursts the missed window. + expect(readState(baseDir, 'a').t1.lastRunAt).toBe('2026-01-01T10:05:00.000Z'); + + now = new Date('2026-01-01T10:10:00.000Z'); + await cron.tick(); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + it('deletes state entries for triggers removed from the spec', async () => { const specs: Record = { a: [CRON, CRON2] }; const { cron, triggersOf } = make(specs); diff --git a/apps/agent-forge/server/__tests__/hooksRoute.test.ts b/apps/agent-forge/server/__tests__/hooksRoute.test.ts index 8c336f27b..1a38d49ff 100644 --- a/apps/agent-forge/server/__tests__/hooksRoute.test.ts +++ b/apps/agent-forge/server/__tests__/hooksRoute.test.ts @@ -82,6 +82,7 @@ function appWith( dispatch, runsOf: () => [], cancel: () => undefined, + isEnabled: () => true, log: () => {}, ...hostExtra, } as unknown as TriggerHost; @@ -309,4 +310,32 @@ describe('POST /hooks/:agentId/', () => { expect(res.body.cancelled).toEqual([]); expect(cancel).not.toHaveBeenCalled(); }); + + it('answers 409 LOUSHO_AGENT_DISABLED for a turned-off agent after auth (R09)', async () => { + const { app, dispatch } = appWith([SIGNED], undefined, { isEnabled: () => false }); + const body = JSON.stringify({ action: 'deploy' }); + const res = await request(app) + .post(`/hooks/${AGENT}/hooks/t1`) + .set('content-type', 'application/json') + .set('x-signature-256', sign(body)) + .send(body); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('LOUSHO_AGENT_DISABLED'); + expect(res.body.error).toBe('Agent is turned off'); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('still rejects an unauthenticated call on a disabled agent before the enabled gate (R09)', async () => { + // Auth runs first: an unsigned remote call gets 403, never the 409 that + // would confirm the agent exists. + const { app } = appWith([OPEN], undefined, { isEnabled: () => false }); + const res = await request(app) + .post(`/hooks/${AGENT}/hooks/open`) + .set('host', 'staging.example.com') + .send('x'); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('This webhook has no secret, so it only accepts local calls.'); + }); }); diff --git a/apps/agent-forge/server/__tests__/triggerHost.test.ts b/apps/agent-forge/server/__tests__/triggerHost.test.ts index 697787c11..d01ad9458 100644 --- a/apps/agent-forge/server/__tests__/triggerHost.test.ts +++ b/apps/agent-forge/server/__tests__/triggerHost.test.ts @@ -46,21 +46,26 @@ class FakeRuns extends EventEmitter { } } -function repoWith(specs: Record): AgentRepo { +function repoWith(specs: Record, metas: Record = {}): AgentRepo { return { listIds: () => Object.keys(specs), readSpec: async (id: string) => specs[id], + readMeta: async (id: string) => ({ enabled: metas[id]?.enabled ?? true }) as never, } as unknown as AgentRepo; } const made: TriggerHost[] = []; -function makeHost(specs: Record, queueCap?: number) { +function makeHost( + specs: Record, + queueCap?: number, + metas: Record = {} +) { const runs = new FakeRuns(); const events = new StudioEventBus(); const logs: string[] = []; const host = new TriggerHost({ - repo: repoWith(specs), + repo: repoWith(specs, metas), runManager: runs as unknown as RunManager, events, log: (_agentId, message) => logs.push(message), @@ -243,4 +248,57 @@ describe('TriggerHost', () => { expect(d2.ok).toBe(true); expect(host.cancel('missing:key', 'x')).toBeUndefined(); }); + + it('rejects dispatches for a disabled agent and isEnabled reflects meta (R09)', async () => { + const metas = { a: { enabled: false } }; + const { host, runs } = makeHost({ a: specWith([WEBHOOK]) }, undefined, metas); + await host.start(); + + expect(host.isEnabled('a')).toBe(false); + expect(host.isEnabled('missing')).toBe(true); + expect(host.dispatch({ agentId: 'a', triggerId: 't1', input: 'x', event: {} })).toEqual({ + ok: false, + reason: 'disabled', + }); + expect(host.runsOf('a')).toHaveLength(0); + expect(runs.calls).toHaveLength(0); + }); + + it('drops queued runs and logs the count when the agent turns off; the running run finishes (R09)', async () => { + const metas = { a: { enabled: true } }; + const { host, runs, logs } = makeHost({ a: specWith([WEBHOOK, CRON]) }, undefined, metas); + await host.start(); + + host.dispatch({ agentId: 'a', triggerId: 't1', input: 'one', event: {} }); + host.dispatch({ agentId: 'a', triggerId: 'c1', input: 'two', event: {} }); + host.dispatch({ agentId: 'a', triggerId: 'c1', input: 'three', event: {} }); + await flush(); + expect(host.runsOf('a').map((r) => r.status)).toEqual(['running', 'queued', 'queued']); + + metas.a.enabled = false; + await host.reload('a'); + + expect(host.isEnabled('a')).toBe(false); + // Only the in-flight run survives; both queued requests are gone. + expect(host.runsOf('a').map((r) => r.status)).toEqual(['running']); + expect(logs).toContain('Dropped 2 queued runs: the agent was turned off'); + expect(runs.calls).toHaveLength(1); + + // The running run still ends normally and reports its RunEnd. + const ends: RunEnd[] = []; + host.onRunEnd((end) => ends.push(end)); + runs.emitStatus('a', 'done', { resultText: 'ok' }); + expect(ends).toHaveLength(1); + expect(ends[0].status).toBe('done'); + }); + + it('does not re-drop or re-log while the agent stays disabled (R09)', async () => { + const metas = { a: { enabled: false } }; + const { host, logs } = makeHost({ a: specWith([WEBHOOK]) }, undefined, metas); + await host.start(); + await host.reload('a'); + await host.reload('a'); + + expect(logs.filter((m) => m.includes('turned off'))).toHaveLength(0); + }); }); diff --git a/apps/agent-forge/server/triggers/channelTriggers.ts b/apps/agent-forge/server/triggers/channelTriggers.ts index b4461ea63..4a56be6d8 100644 --- a/apps/agent-forge/server/triggers/channelTriggers.ts +++ b/apps/agent-forge/server/triggers/channelTriggers.ts @@ -388,6 +388,14 @@ export class ChannelTriggers { return; } + // R09: a turned-off agent's channels answer 200 skipped so platforms + // do not retry; the SDK handler (which would run the agent) is never + // called, and abort listeners do not run either. + if (!this.deps.host.isEnabled(route.agentId)) { + res.status(200).json({ ok: true, skipped: 'disabled' }); + return; + } + let body: Buffer; try { body = await readBody(req); diff --git a/apps/agent-forge/server/triggers/triggerHost.ts b/apps/agent-forge/server/triggers/triggerHost.ts index 1e3cea37f..59289bca3 100644 --- a/apps/agent-forge/server/triggers/triggerHost.ts +++ b/apps/agent-forge/server/triggers/triggerHost.ts @@ -98,6 +98,8 @@ const END_STATUSES = new Set(['done', 'error', 'stopped']); export class TriggerHost { private readonly queueCap: number; private readonly agents = new Map(); + /** `meta.enabled` per agent, refreshed on every reload (R09); absent means enabled. */ + private readonly enabled = new Map(); private readonly endListeners = new Set<(end: RunEnd) => void>(); private unsubscribers: Array<() => void> = []; private stopped = false; @@ -137,6 +139,7 @@ export class TriggerHost { const triggers = (spec as AgentSpecV2 | undefined)?.triggers ?? []; const state = this.stateFor(id); state.triggers = triggers; + await this.refreshEnabled(id, state); const known = new Set(triggers.map((t) => t.id)); const dropped = state.queue.filter((run) => !known.has(run.triggerId)); if (dropped.length > 0) { @@ -158,10 +161,39 @@ export class TriggerHost { return this.agents.get(agentId)?.triggers ?? []; } + /** + * `meta.enabled` for the agent (R09): false rejects every trigger + * dispatch while manual runs stay untouched. Unknown agents read as + * enabled, matching `defaultMeta`. + */ + isEnabled(agentId: string): boolean { + return this.enabled.get(agentId) ?? true; + } + + /** + * Re-reads `meta.enabled` into the cache; on a transition to disabled + * every queued host run is dropped (a running one finishes - R09). + */ + private async refreshEnabled(agentId: string, state: AgentTriggers): Promise { + // Test fakes may omit readMeta; a missing meta reads as enabled. + const meta = + this.deps.repo.readMeta === undefined + ? undefined + : await this.deps.repo.readMeta(agentId).catch(() => undefined); + const nowEnabled = meta?.enabled ?? true; + const wasEnabled = this.enabled.get(agentId) ?? true; + this.enabled.set(agentId, nowEnabled); + if (wasEnabled && !nowEnabled && state.queue.length > 0) { + const dropped = state.queue.splice(0); + this.deps.log(agentId, `Dropped ${dropped.length} queued runs: the agent was turned off`, 'warn'); + } + } + /** Accepts a trigger request: enqueues FIFO, starts immediately when the agent is free. */ dispatch(req: DispatchRequest): DispatchResult { - // R09 seam: the enabled-only gate lands here - reject with 'disabled' - // when the agent's meta.enabled is false before touching the queue. + // R09: a turned-off agent starts no triggered run (webhook/channel map + // this to their own skip responses; cron/chain log the skip). + if (!this.isEnabled(req.agentId)) return { ok: false, reason: 'disabled' }; const trigger = this.triggersOf(req.agentId).find((t) => t.id === req.triggerId); if (!trigger) return { ok: false, reason: 'unknown_trigger' }; diff --git a/apps/agent-forge/server/triggers/webhookRoute.ts b/apps/agent-forge/server/triggers/webhookRoute.ts index 7525b2aa2..5501c0b66 100644 --- a/apps/agent-forge/server/triggers/webhookRoute.ts +++ b/apps/agent-forge/server/triggers/webhookRoute.ts @@ -3,7 +3,12 @@ * * POST /hooks/:agentId/ -> 202 { queued, runKey, started, cancelled } * | 202 { skipped: 'filter' } - * | 400/401/403/404/422/429 + * | 400/401/403/404/409/422/429 + * + * `cancelled` lists the runKeys abort listeners (R03b) stopped for this + * event, evaluated after signature verification and before the filter. + * A turned-off agent (`meta.enabled === false`, R09) answers 409 + * `LOUSHO_AGENT_DISABLED` right after auth - no event processing beyond it. * * `cancelled` lists the runKeys abort listeners (R03b) stopped for this * event, evaluated after signature verification and before the filter. @@ -117,6 +122,13 @@ export function registerWebhookRoutes(app: Express, deps: WebhookRouteDeps): voi const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); if (!(await checkSignature(req, res, trigger, rawBody, deps.resolveSecret))) return; + // R09: a turned-off agent does no event processing beyond auth - + // abort listeners, filter and dispatch all wait for it to be on. + if (!ws.triggers.isEnabled(agentId)) { + sendError(res, 409, 'Agent is turned off', 'LOUSHO_AGENT_DISABLED'); + return; + } + const event = eventOf(rawBody); // Abort listeners run after signature verification but before the @@ -158,7 +170,7 @@ export function registerWebhookRoutes(app: Express, deps: WebhookRouteDeps): voi return; } if (result.reason === 'disabled') { - sendError(res, 403, `Trigger '${trigger.id}' is disabled.`, 'LOUSHO_TRIGGER_DISABLED'); + sendError(res, 409, 'Agent is turned off', 'LOUSHO_AGENT_DISABLED'); return; } sendError(res, 404, `No webhook trigger '${trigger.id}' for agent '${agentId}'`); diff --git a/apps/agent-forge/src/canvas/NodeCard.tsx b/apps/agent-forge/src/canvas/NodeCard.tsx index bb8f32549..5d5dc2421 100644 --- a/apps/agent-forge/src/canvas/NodeCard.tsx +++ b/apps/agent-forge/src/canvas/NodeCard.tsx @@ -32,6 +32,8 @@ export interface CanvasNodeData extends Record { graph?: Graph; /** Group nodes only: how many descendants the group hides while collapsed. */ insideCount?: number; + /** `meta.enabled` of the open agent (R09); false dims non-chat trigger nodes. Absent reads enabled. */ + agentEnabled?: boolean; /** * True on a canvas that shows a graph it does not own (X06b's code-agent * view): node affordances that would write through `AppState.setGraph` @@ -196,9 +198,16 @@ export function NodeCard({ const chips = [...(chip !== undefined ? [chip] : []), ...(extraChips ?? [])]; // A proposal-edited node carries an "edited" marker chip (C21). const edited = graphNode.proposed === 'edit'; + // R09: a turned-off agent's non-chat trigger nodes dim with the contract tooltip. + const triggerOff = data.agentEnabled === false && graphNode.kind === 'trigger' && graphNode.data.item.type !== 'chat'; return ( -
+
{view.ports.in && } {view.ports.out && } void; /** Node ids with a pinned tool result (T04); their cards show the Pin badge. */ pinnedNodeIds?: ReadonlySet; + /** `meta.enabled` of the open agent (R09); false dims non-chat trigger nodes. Absent reads enabled. */ + agentEnabled?: boolean; /** X06b: the canvas does not own this graph — node affordances that write through AppState stay disabled. */ readOnly?: boolean; } @@ -190,7 +192,7 @@ function groupSizes(graph: Graph, measured: MeasuredSizes): Map[] { - const { selectedNodeId, highlightedNodeId, breakpoints, statuses, problems, openNodeSpans, pinnedNodeIds, readOnly } = decor; + const { selectedNodeId, highlightedNodeId, breakpoints, statuses, problems, openNodeSpans, pinnedNodeIds, agentEnabled, readOnly } = decor; const sizes = groupSizes(graph, measured); return canvasNodeOrder(graph).map((n) => { const group = isGroupNode(n); @@ -214,6 +216,7 @@ export function toRfNodes(graph: Graph, decor: NodeDecor, measured: MeasuredSize problems: problems?.get(n.id) ?? [], onViewSpans: openNodeSpans, pinned: pinnedNodeIds?.has(n.id) === true, + agentEnabled, readOnly, insideCount: group ? descendantCount(graph, n.id) : undefined, }, @@ -1074,6 +1077,7 @@ function useCanvasElements() { agentId, graph, displayGraph, + agents, proposal, hideProposal, setGraph, @@ -1104,6 +1108,9 @@ function useCanvasElements() { // The canvas node ids (`tool:`) carrying at least one pinned tool // result - their cards show the Pin badge (T04). const pinnedNodeIds = useMemo(() => new Set(pins.map((pin) => pin.nodeId)), [pins]); + // R09: the list item's `enabled` dims non-chat trigger nodes; an agent + // missing from the list (just created, mid-delete) reads enabled. + const agentEnabled = useMemo(() => agents.find((a) => a.id === agentId)?.enabled ?? true, [agents, agentId]); const problemsByNode = useMemo(() => { const map = new Map(); for (const problem of problems) { @@ -1117,10 +1124,10 @@ function useCanvasElements() { () => toRfNodes( displayGraph, - { selectedNodeId, highlightedNodeId, breakpoints, statuses, problems: problemsByNode, openNodeSpans, pinnedNodeIds }, + { selectedNodeId, highlightedNodeId, breakpoints, statuses, problems: problemsByNode, openNodeSpans, pinnedNodeIds, agentEnabled }, measured ), - [displayGraph, selectedNodeId, highlightedNodeId, breakpointsKey, statuses, problemsByNode, openNodeSpans, pinnedNodeIds, measured] + [displayGraph, selectedNodeId, highlightedNodeId, breakpointsKey, statuses, problemsByNode, openNodeSpans, pinnedNodeIds, agentEnabled, measured] ); const [selectedEdgeId, setSelectedEdgeId] = useAgentEdgeSelection(agentId); // Edges derive from the DISPLAY graph so proposal nodes are wired in; diff --git a/apps/agent-forge/src/screens/agentDetail/AgentOverviewTab.tsx b/apps/agent-forge/src/screens/agentDetail/AgentOverviewTab.tsx index b44038fc6..5f8156d14 100644 --- a/apps/agent-forge/src/screens/agentDetail/AgentOverviewTab.tsx +++ b/apps/agent-forge/src/screens/agentDetail/AgentOverviewTab.tsx @@ -2,7 +2,7 @@ import { ChevronRight, TriangleAlert } from 'lucide-react'; import { useEffect, useState, type ReactNode } from 'react'; import type { AgentSpecV2 } from 'lousho/spec'; -import type { ChannelTriggerStatus, TraceSummaryPayload } from '../../../shared/wireTypes'; +import type { AgentMeta, ChannelTriggerStatus, TraceSummaryPayload } from '../../../shared/wireTypes'; import { agentTabPath, BUILT_AGENT_TABS } from '../agents/agentTabs'; import { fmtAge, fmtDateTime, fmtDuration } from '../../observe/format'; import { Card } from '../../ui/Card'; @@ -158,11 +158,34 @@ function CapabilitiesCard({ rows }: { rows: CapabilityRow[] }): JSX.Element { ); } +/** + * The off-state note (R09): shown when `meta.enabled` is false and the + * agent has triggers that would fire - auto-disabled agents get the + * failure-streak copy instead. Em dashes are part of the contract copy. + */ +function triggersOffNote(meta: AgentMeta): string { + return meta.disabledReason === 'auto_failure_streak' + ? 'Turned off after 5 failed triggered runs in a row. Fix the cause, then turn it back on.' + : 'Off — triggers won\'t fire. Turn the agent on to start them.'; +} + /** The Triggers card: one icon + line per trigger (webhooks and channels also show their URL), or the empty copy. */ -function TriggersCard({ agentId, triggers }: { agentId: string; triggers: TriggerSpec[] }): JSX.Element { +function TriggersCard({ + agentId, + triggers, + meta, +}: { + agentId: string; + triggers: TriggerSpec[]; + meta: AgentMeta; +}): JSX.Element { const channelStatuses = useChannelStatuses(agentId); + const hasFirableTriggers = triggers.some((trigger) => trigger.type !== 'chat'); return ( + {meta.enabled === false && hasFirableTriggers && ( +

{triggersOffNote(meta)}

+ )} {triggers.length > 0 ? (
    {triggers.map((trigger) => { @@ -320,7 +343,7 @@ export function AgentOverviewTab() { )} - +
diff --git a/apps/agent-forge/src/screens/agentDetail/__tests__/AgentOverviewTab.test.tsx b/apps/agent-forge/src/screens/agentDetail/__tests__/AgentOverviewTab.test.tsx index e144fb097..64e929f3b 100644 --- a/apps/agent-forge/src/screens/agentDetail/__tests__/AgentOverviewTab.test.tsx +++ b/apps/agent-forge/src/screens/agentDetail/__tests__/AgentOverviewTab.test.tsx @@ -28,7 +28,7 @@ function meta(): AgentRecord['meta'] { }; } -function record(over: { spec?: Partial; requirements?: Requirement[] } = {}): AgentRecord { +function record(over: { spec?: Partial; requirements?: Requirement[]; meta?: Partial } = {}): AgentRecord { return { id: 'support-bot', spec: { @@ -38,7 +38,7 @@ function record(over: { spec?: Partial; requirements?: Requirement[ instructions: 'Be helpful.', ...over.spec, } as AgentSpecV2, - meta: meta(), + meta: { ...meta(), ...over.meta }, layout: { schemaVersion: 1, nodes: {}, notes: [] }, updatedAt: '2026-10-29T10:00:00Z', latestVersion: null, @@ -207,6 +207,43 @@ describe('AgentOverviewTab', () => { expect(triggersCard.textContent).toContain('Channel runs show up in Runs and Traces.'); }); + it('shows the off note on the Triggers card for a disabled agent with firable triggers (R09)', async () => { + const triggers: AgentSpecV2['triggers'] = [ + { id: 't1', type: 'chat' }, + { id: 't2', type: 'cron', cron: '0 9 * * *', prompt: 'x' }, + ]; + const host = renderTab(record({ spec: { triggers }, meta: { enabled: false } })); + await flush(); + + const note = card(host, 'Triggers').querySelector('.triggers-off-note'); + expect(note?.textContent).toBe("Off — triggers won't fire. Turn the agent on to start them."); + }); + + it('shows the auto-disabled copy when the streak turned the agent off (R09)', async () => { + const triggers: AgentSpecV2['triggers'] = [{ id: 't2', type: 'cron', cron: '0 9 * * *', prompt: 'x' }]; + const host = renderTab( + record({ spec: { triggers }, meta: { enabled: false, disabledReason: 'auto_failure_streak' } }), + ); + await flush(); + + expect(card(host, 'Triggers').querySelector('.triggers-off-note')?.textContent).toBe( + 'Turned off after 5 failed triggered runs in a row. Fix the cause, then turn it back on.', + ); + }); + + it('hides the off note for an enabled agent or one with only a chat trigger (R09)', async () => { + const cron: AgentSpecV2['triggers'] = [{ id: 't2', type: 'cron', cron: '0 9 * * *', prompt: 'x' }]; + const enabled = renderTab(record({ spec: { triggers: cron } })); + await flush(); + expect(card(enabled, 'Triggers').querySelector('.triggers-off-note')).toBeNull(); + + const chatOnly = renderTab( + record({ spec: { triggers: [{ id: 't1', type: 'chat' }] }, meta: { enabled: false } }), + ); + await flush(); + expect(card(chatOnly, 'Triggers').querySelector('.triggers-off-note')).toBeNull(); + }); + it('shows permission-rule counts by action in the Safety card', async () => { const host = renderTab( record({ diff --git a/apps/agent-forge/src/screens/agentDetail/agentDetail.css b/apps/agent-forge/src/screens/agentDetail/agentDetail.css index c909d7b72..96a555a55 100644 --- a/apps/agent-forge/src/screens/agentDetail/agentDetail.css +++ b/apps/agent-forge/src/screens/agentDetail/agentDetail.css @@ -171,10 +171,13 @@ a.readiness-chip:focus-visible { font-size: 12px; font-weight: 500; color: var(--text); transition: background-color var(--dur-fast) var(--ease); } .enabled-control:hover { background: var(--surface-2); } .enabled-control[data-pending] { cursor: progress; } -.agent-detail-notice { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-2) var(--space-3); +.agent-detail-notice, .triggers-off-note { padding: var(--space-2) var(--space-3); background: var(--warning-soft); border: 1px solid var(--warning-border); border-radius: var(--radius); font-size: 12px; color: var(--text); } +.agent-detail-notice { display: flex; align-items: flex-start; gap: var(--space-2); } .agent-detail-notice svg { flex: none; margin-top: 2px; color: var(--warning); } .agent-detail-notice p { margin: 0; } +/* R09: the Triggers card's off note (agentOverview.css places it in the card flow). */ +.triggers-off-note { margin: 0 0 var(--space-2); } .agent-detail-labels { display: flex; gap: 4px; list-style: none; margin: 0; padding: 0; } .agent-detail-label, .agent-detail-label-more { height: 20px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--radius-full); background: var(--surface-2); font-size: 11px; line-height: 18px; color: var(--text-muted); } diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index c4963871f..052e91cf4 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -802,6 +802,10 @@ button.breakpoint-toggle { .canvas-wrap .rf-node.running { border-color: var(--accent); } +/* R09: a turned-off agent's non-chat trigger nodes dim (the title tooltip says why). */ +.rf-node.node-trigger-off { + opacity: 0.6; +} .canvas-wrap .rf-node.running::after { content: ''; position: absolute;