Remove chat backend routes only the web chat used

The in-app Assistant chat window was retired in #3126. Several backend
routes existed only for it, and Pulse Mobile, the remaining chat client,
never called them: assistant surface-tools, workflow-prompt render,
question answers, and the session summarize, fork, undo, redo, steer,
diff, revert and unrevert sub-resources. This removes them along with
the handlers, service and session-store methods, the persisted redo
stack, session compaction, mid-turn steering and the question-answer
wait behind them.

The question tool needed more than a route removal. Interactive chat
still offered pulse_question, and Pulse Mobile has no way to answer it,
so a Mobile turn that called it waited until the request ended. No
profile offers it now. The agentic loop answers any call with a refusal
that tells the model to ask in its reply text, the path Patrol runs
already took, and the chat prompt drops its clarification-tool advice.
The capabilities manifest's Assistant surface stops declaring
interactive questions, so the generated docs and fallback governance
names follow it.

/api/ai/chat and the session list, create, rename, delete, messages and
abort routes Pulse Mobile uses are unchanged, as is /api/ai/status. Old
session files load as before and drop the redo stack and steered flag
on their next write. docs/API.md lists the remaining chat routes, and
the subsystem contracts record the removal.
This commit is contained in:
rcourtman
2026-10-23 14:07:20 +01:00
parent 3b5d789aeb
commit dcbfd2d86d
51 changed files with 428 additions and 4265 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ governed actions.
<!-- pulse-mcp-surface-contract:start -->
- **Pulse Intelligence Core**: Canonical context, governed actions, safety gates, approval state, action audit, and verification shared by Pulse Assistant, Pulse MCP, and Pulse Patrol.
- **Pulse Patrol**: Patrol is the first-party operations surface: it checks infrastructure, investigates issues, follows the chosen Patrol mode before acting, verifies outcomes, and records what happened.
- **Pulse Assistant** (the native Pulse surface): The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.
- **Pulse Assistant** (the native Pulse surface): The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.
- **Pulse MCP**: The external-agent adapter that projects canonical Pulse Intelligence capabilities as MCP tools. Affordances: tools, resources, prompts, and capability metadata.
<!-- pulse-mcp-surface-contract:end -->
+1 -1
View File
@@ -12,7 +12,7 @@ Pulse Intelligence is built around a shared **Pulse Intelligence Core**: Canonic
That core is deliberately surfaced with Patrol as the primary built-in operator and Assistant plus MCP as access paths over the same governed capabilities:
1. **Pulse Patrol**: Patrol is the first-party operations surface: it checks infrastructure, investigates issues, follows the chosen Patrol mode before acting, verifies outcomes, and records what happened.
2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.
2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.
3. **Pulse MCP**: The external-agent adapter that projects canonical Pulse Intelligence capabilities as MCP tools. Affordances: tools, resources, prompts, and capability metadata.
<!-- pulse-intelligence-overview:end -->
+8 -13
View File
@@ -1548,25 +1548,20 @@ Runs an AI request which may return tool calls, findings, or suggested actions.
### Execute (Streaming)
`POST /api/ai/execute/stream`
Streaming variant of execute (used by the UI for incremental responses).
Streaming variant of execute.
### Assistant Chat & Sessions
- `GET /api/ai/status`
### Chat Sessions (Pulse Mobile)
The web chat window is retired; these routes serve Pulse Mobile chat until
the app retires on 31 March 2027. To ask about your infrastructure from an
agent, connect an MCP client through `pulse-mcp` instead.
- `GET /api/ai/status` reports whether the chat service is running
- `POST /api/ai/chat` (streaming)
- `GET /api/ai/sessions` returns session summaries; scoped Assistant handoffs may include a safe `handoff_summary` marker without model-only context text or command payloads.
- `GET /api/ai/sessions` returns session summaries; scoped handoffs may include a safe `handoff_summary` marker without model-only context text or command payloads.
- `POST /api/ai/sessions`
- `PATCH /api/ai/sessions/{id}` (rename)
- `DELETE /api/ai/sessions/{id}`
- `GET /api/ai/sessions/{id}/messages`
- `POST /api/ai/sessions/{id}/abort`
- `POST /api/ai/sessions/{id}/summarize`
- `POST /api/ai/sessions/{id}/fork`
- Legacy OpenCode-style file-change routes (`GET /api/ai/sessions/{id}/diff`,
`POST /api/ai/sessions/{id}/revert`, `POST /api/ai/sessions/{id}/unrevert`)
return `501 Not Implemented`; Pulse Assistant sessions do not own file diffs
or file-level revert.
### Question Answers
- `POST /api/ai/question/{id}/answer`
### Kubernetes AI Analysis (Compatibility)
`POST /api/ai/kubernetes/analyze`
@@ -10192,3 +10192,10 @@ starting write from a run whose session began before a regression this process
observed, so a run that finishes after its finding was detected again or
re-investigated leaves the newer state alone. It enrols, commands, updates or
removes no agent and touches no agent identity or credential.
The `internal/api` change of 2026-10-23 removes browser-only chat routes
(`/api/ai/assistant/surface-tools`, `/api/ai/workflow-prompts/render`,
`/api/ai/question/{id}/answer`, and the `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`, `/diff`, `/revert`, and `/unrevert` session sub-resources). The adjacent-boundary notes above that name those routes are now
history; none of them was agent-lifecycle state. No agent enrollment,
command, update, identity, or credential path changes.
@@ -1043,8 +1043,9 @@ Any first-party workflow starter surface is a projection of that same
catalogue, not a second prompt registry (the browser Assistant that hosted the
starters was retired on 2026-10-22): it may decide which manifest-owned prompt
is contextually usable, but display labels and rendered prompt text must come
from shared manifest metadata and the shared backend renderer on
`POST /api/ai/workflow-prompts/render`.
from shared manifest metadata and the shared `internal/agentcapabilities`
renderer that MCP `prompts/get` uses (the browser-only
`POST /api/ai/workflow-prompts/render` route was removed on 2026-10-23).
AI provider integration is registry-owned. Provider identity, display names,
protocol family, default model, default base URL, credential fields,
configured-state fields, clear-key fields, env-var hints, docs links, and
@@ -1505,9 +1506,7 @@ wording outside the manifest-owned surface contract.
Surface tool summaries follow the same shared-core rule. The native Assistant
runtime exposes provider tools through `ProjectPulseAssistantProviderTools`,
which applies the manifest-owned Pulse Assistant surface affordances before
registry or native interaction tools reach model requests, and exposes
`PulseToolExecutor.AssistantSurfaceToolContract`, which projects the same
runtime-available provider tools through `ProjectPulseAssistantSurfaceToolContract`;
registry or native interaction tools reach model requests;
external-agent adapters project request/response manifest capabilities through
`ProjectManifestSurfaceToolContract` and publish the static external-adapter
inventory on `Manifest.SurfaceToolContracts`. Missing external-adapter
@@ -1583,11 +1582,11 @@ finding-resolution detail. External-agent prompts and MCP adapters may treat a
rejected-only decision as a terminal no-execution outcome, but approved
decisions must still continue through execution and verification before the
verified-outcome stage is complete.
Runtime Assistant availability remains an authenticated Assistant concern,
served by `GET /api/ai/assistant/surface-tools`, not a static fact in the
public agent-capabilities manifest. A client that displays live capability
availability must load that authenticated endpoint, and a browser client formats
the posture through `frontend-modern/src/api/agentCapabilities.ts`; no client
Runtime Assistant availability is not a static fact in the public
agent-capabilities manifest. The authenticated
`GET /api/ai/assistant/surface-tools` route that served it to the browser
Assistant was removed on 2026-10-23 with the retired chat window, and no
client displays live Assistant tool availability; no client
may keep a local registry-tool catalogue or reuse MCP manifest capability names
as native Assistant inventory. The External agents settings panel may show the
external Pulse MCP tool posture, but it must get the normalized static
@@ -2167,9 +2166,11 @@ Shared projected-tool behavior hints are the same class of boundary: the
neutral projection owns `ToolBehaviorHints`, while any `MCPToolAnnotations`
name is an alias in the MCP wire edge only.
Assistant-only provider tools are still shared Pulse Intelligence declarations:
the native `pulse_question` clarification tool must be included in provider
the native `pulse_question` clarification tool is declared for provider
tool lists through `agentcapabilities.ProjectPulseAssistantProviderTools` and
`agentcapabilities.AssistantNativeProviderTools`, its declaration must come
`agentcapabilities.AssistantNativeProviderTools` (no chat profile offers it
since 2026-10-23: no Pulse client can answer it, and the agentic loop pairs
any call to it with a refusal instead of waiting), its declaration must come
from `agentcapabilities.NewPulseQuestionProviderTool`, its JSON Schema must
remain owned by `agentcapabilities.PulseQuestionProviderInputSchema`, its
question type/defaulting vocabulary must use
@@ -2183,8 +2184,8 @@ tool-call artifact detection and streaming partial-tool-name holding must use
`agentcapabilities.ProviderToolCallArtifactIndex` and
`agentcapabilities.SplitTrailingProviderToolNamePrefix` with that shared catalog,
so Assistant and future adapter boundaries do not fork DSML/XML/pipe/JSON/function
leak semantics. Chat may own waiting for user answers, UI event
emission, and user-answer delivery, but it must not carry a chat-local
leak semantics. Chat no longer waits for user answers or emits question
events, and it must not carry a chat-local
provider schema, description, tool-list append rule, prompt-filter rule, parser,
tool identity, or provider artifact detector for that interaction.
That schema boundary is also a copy boundary: registry tool definitions must
@@ -2339,9 +2340,7 @@ thought-signature, and provider-result normalization as Assistant and MCP —
UNIVERSAL backstop rule: EVERY code path that sends infrastructure-derived
content to an external model MUST install `RequestSanitizerForModel` before the
provider request — not only the interactive agentic loop. This explicitly
includes session compaction (`internal/ai/chat/session_compaction.go`
`SummarizeSession`), which sends the PERSISTED transcript (original user prompts
and tool outputs carry raw identifiers), and the shared service helper
includes the shared service helper
`(*Service).requestSanitizerForModel` (`internal/ai/service.go`) used by
discovery analysis, the report and fleet narrators, quick analysis, and the
ExecuteAgentic paths. A new model-bound request path that skips the sanitizer is
@@ -2363,21 +2362,8 @@ thought-signature, and provider-result normalization as Assistant and MCP —
session, stream, and provider rules below stay in force. The drawer,
composer, slash-command, transcript-rendering, and browser dev-fixture
rules that used to live in this item left with the window.
Assistant session compaction is a runtime-backed session workflow, not a
local waiting message, transcript-only UI action, or stubbed summarize
endpoint. The referenced OpenCode source at fetched `origin/dev` commit
`e82542b8023a8374f29c23b70ec019c8f256354e` registers `/compact` with
`/summarize` as an alias in
`packages/tui/src/routes/session/index.tsx` lines 548-570 and calls the
runtime session summary action with the current provider/model route. Pulse
adapts that behavior through `POST /api/ai/sessions/{id}/summarize`,
`chat.Service.SummarizeSession`, and
`SessionStore.CompactWithSummary`: the backend must reject active-running
sessions, build a bounded and redacted transcript for the active chat model,
record provider token usage as `assistant_session_compaction`, persist one
visible assistant summary row plus the latest bounded turns, clear redo
state, and never stream or persist raw provider tool-call artifacts, hidden
reasoning, secrets, or raw provider responses as the compacted transcript.
Assistant session compaction (`POST /api/ai/sessions/{id}/summarize`) was
removed on 2026-10-23 with the browser Assistant, its only caller.
OpenRouter-routed Assistant chat requests must set a bounded default
completion budget when the runtime request does not specify `MaxTokens`.
OpenRouter preflights affordability against the requested maximum completion
@@ -2415,19 +2401,9 @@ thought-signature, and provider-result normalization as Assistant and MCP —
route that actually completed the selected turn, and provider retry copy
must say Pulse is retrying the selected route rather than imply automatic
provider or model fallback.
Follow-up steering runs through `POST /api/ai/sessions/{id}/steer`. A
steering-accepted follow-up joins the loop at its next turn boundary (the
abort-check site in `agentic.go executeWithTools`) as a plain user message,
never mid-provider-stream and never mid-tool-batch; the loop announces the
injection with a `steer_applied` stream event carrying the client row id,
splices the message into `resultMessages` at its true position, and
persists it through the end-of-run save (whose skip-user-messages rule
exempts `Steered` messages). Steering carries prompt text only: it cannot
change the model route, control level, or autonomous mode of the running
turn, does not extend the turn budget or reset wrap-up brakes, and is
rejected for system sessions. Delivery is not guaranteed: a run that
finishes before a boundary discards unconsumed steers without persisting
them.
Mid-turn steering (`POST /api/ai/sessions/{id}/steer`, the agentic loop's
steering inbox, and the `steer_applied` stream event) was removed on
2026-10-23 with the browser Assistant, its only caller.
Primary interactive chat model resolution must use the explicit configured
chat route or, when no explicit chat route exists, a stable provider default
without calling provider model catalogs before the selected stream starts. If
@@ -2661,25 +2637,10 @@ thought-signature, and provider-result normalization as Assistant and MCP —
summaries `system: true` in every `Session` projection, so clients can keep
them out of resume lists. The canonical Patrol forensic surface remains the
`PatrolRunRecord` history at `/api/ai/patrol/runs`.
Assistant session forking is a runtime-backed session workflow, not a
header-only UI affordance. The referenced OpenCode source in
`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` registers
`session.fork` as a first-class session command and opens
`DialogForkFromTimeline` from the current session route. Pulse adapts that
source behavior through `POST /api/ai/sessions/{id}/fork`: the local
`chat.Service` must route to `SessionStore.Fork`, and the store must create
a fresh durable session ID with fresh create/update timestamps while cloning
the saved transcript and client-safe model handoff summary. Copied
messages keep their per-session message/tool IDs so assistant tool-call and
tool-result relationships remain intact inside the fork. Forking must not
mutate the source session, must continue through normal session list/load
contracts, and must not return `not_implemented` from the local runtime.
OpenCode-style session file diff/revert workflows do not apply to Pulse
Assistant sessions: Pulse does not own local code-file edits, and
infrastructure mutations are governed through approval/action history. Legacy direct calls to
`/api/ai/sessions/{id}/diff`, `/revert`, and `/unrevert` must fail
explicitly with `501 Not Implemented` rather than returning a success-shaped
placeholder.
Session forking and the unsupported OpenCode-style
`/api/ai/sessions/{id}/diff`, `/revert`, and `/unrevert` routes were removed
on 2026-10-23 with the browser Assistant; they answer the session router's
unknown-subresource `404`.
Assistant session rename is part of the same session workflow. Pulse owns it
through `PATCH /api/ai/sessions/{id}`, `chat.Service.RenameSession`, and
`SessionStore.Rename`; saving must persist the normalized title, return the
@@ -2697,26 +2658,12 @@ thought-signature, and provider-result normalization as Assistant and MCP —
record usage as `assistant_session_title`, and a failed or timed-out
title call leaves the placeholder in place rather than failing anything
user-visible.
Assistant turn undo/redo is a session repair workflow, not a
transcript-local delete. Pulse owns it through
`POST /api/ai/sessions/{id}/undo` and `POST /api/ai/sessions/{id}/redo`:
the backend session store must remove the latest user-authored turn plus
following assistant/tool messages as one durable turn, persist a bounded
redo stack, expose `can_redo` on the client-safe session summary, clear redo
state when a new message or trim changes the transcript, and keep forked
sessions independent from the source redo stack.
Retry and regenerate are turn re-runs over the same undo boundary, not a
parallel history mutation path. `POST /api/ai/sessions/{id}/undo` accepts an
optional JSON body (`chat.SessionTurnUndoOptions`) whose `expected_prompt`
field, when non-empty, makes the removal conditional on the latest
user-authored prompt matching the prompt being re-run, so a retry whose
failed send never reached the session cannot remove a different turn.
Session mutations stay POST-only at the handler: abort, summarize, fork,
undo, redo, and steer refuse every other method with `405` and
`Allow: POST` before resolving the Assistant service. A `GET` or `HEAD`
that the demo-mode read-only guard or the CSRF check admits as a read
therefore cannot cancel a running turn, compact or rewind a transcript,
or persist a fork.
Turn undo/redo, retry over the undo boundary, and the persisted redo stack
were removed on 2026-10-23 with the browser Assistant.
`POST /api/ai/sessions/{id}/abort`, the remaining session mutation, refuses
every other method with `405` and `Allow: POST` before resolving the
Assistant service, so a `GET` or `HEAD` that the demo-mode read-only guard
or the CSRF check admits as a read cannot cancel a running turn.
Assistant output hygiene is part of the same boundary: provider reasoning
and raw serialized tool-call artifacts must never serialize into
client-facing chat streams or persisted assistant prose. Client-bound
@@ -4832,29 +4779,18 @@ resolved context) BEFORE provider projection and clone that executor
for every provider attempt, so the offered schema and the runtime
boundary always agree; scheduled Patrol (`ExecutePatrolStream`) applies
the detection profile the same way, and `ListAvailableTools` projects
through the identical path. Non-interactive profiles independently hide
pulse_question from the manifest AND runtime-block it in the agentic
loop before the interactive-call-set special case - a fabricated
question call returns a non-interactive error without emitting a
waiting event and sibling tool calls from the same provider turn keep
processing; approval waits never block (they queue). Assistant and Watch
through the identical path. No profile offers pulse_question (since
2026-10-23 no Pulse client can answer it, and the manifest's Assistant
surface no longer declares interactive questions); the agentic loop
refuses any call to it with an error tool result - non-interactive
wording for Patrol, ask-in-your-reply wording for chat - without
emitting a waiting event, and sibling tool calls from the same provider
turn keep processing; approval waits never block (they queue). Assistant and Watch
do not infer diagnostic sufficiency from tool-call totals or silent tool turns.
Configured run limits and repeated identical-call/error recovery still bound
execution. Tool results never receive count-based wrap-up instructions.
Interactive runs additionally enforce a
look-before-asking gate: a `pulse_question` issued before the run has
attempted any real tool call is refused with an error tool result that
steers the model to read-only enumeration (the resolve-before-asking
prompt policy made enforceable — small local models otherwise open
natural first questions like "any alerts?" with a "which resource?"
elicitation whose answer is derivable from enumeration). The refusal
emits no question card or clarify event, sibling tool calls from the
same provider turn keep processing rather than tripping the
interactive-call-set skip path, any queued tool attempt (success or
error) satisfies the gate for the rest of the run, and the gate fails
open after `maxLookGateBlocks` refusals so a genuinely unanswerable
prompt cannot livelock. The stream-boundary promise — a natural first
question produces an answer, never a clarification card — is pinned in
The stream-boundary promise - a question call never produces a
clarification card or stalls the turn - is pinned in
the interaction scenario corpus. The system prompt describes detection and
investigation modes directly instead of claiming controlled or
autonomous execution. Non-interactive operation grants no mutation
@@ -7049,8 +6985,8 @@ The runtime retains the configured turn, evidence-call, token and spend limits,
cancellation and registered invocation permissions. Repeated identical reads
and several failed calls do not independently remove tools. These may be useful
rechecks or evidence of missing access. The model chooses how to continue within
those explicit limits. First-turn clarification is delivered through the normal
interactive question protocol without demanding an unrelated read first.
those explicit limits. A model that needs clarification asks in its reply text:
since 2026-10-23 no profile offers the interactive question tool.
Removed metrics include workflow-machine blocks and inferred policy-block
self-correction opportunities/success. Actual strict-resolution and routing
@@ -7059,7 +6995,7 @@ measurements. Independent action receipts and labelled qualification truth own
outcome verification.
Regression sources: `internal/ai/chat/agentic_action_gate_test.go`,
`internal/ai/chat/agentic_look_gate_test.go`, `internal/ai/chat/agentic_test.go`,
`internal/ai/chat/agentic_question_tool_test.go`, `internal/ai/chat/agentic_test.go`,
`internal/ai/tools/control_targets_test.go`. Current-source real-model and browser
qualification is recorded in the customer-journey plan. Historical passes do
not satisfy that gate. `TestAgenticLoop_GeminiDiscoveryToLifecyclePlan` also exercises
@@ -7599,3 +7535,25 @@ Proofs:
This changes which writes land, not what they record. It widens no mutation,
approval or execution boundary.
Backend routes that only the retired browser chat window called were removed
on 2026-10-23: `/api/ai/assistant/surface-tools`, `/api/ai/workflow-prompts/render`,
`/api/ai/question/{id}/answer`, and the `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`, `/diff`, `/revert`, and `/unrevert` session sub-resources. Pulse Mobile chat never used them and keeps
`/api/ai/chat`, the session list, create, rename, delete, messages, and abort
routes, and `/api/ai/status`. With them went `chat.Service` fork, undo, redo,
steer, summarize, answer-question, and surface-tool methods, the session
store's fork and turn-undo operations and persisted redo stack, session
compaction, the agentic loop's steering inbox and question-answer wait, the
`steered` message flag, and the `question` and `steer_applied` stream events.
No chat profile offers `pulse_question` any more: Pulse Mobile has no question
UI and no other client can answer it, so an interactive turn that called it
used to wait until the request ended. The agentic loop now pairs every
`pulse_question` call with a refusal (non-interactive Patrol runs keep their
existing wording) and continues, and the chat system prompt tells the model
to ask in its reply text. The canonical manifest's Pulse Assistant surface no
longer declares interactive questions, so the prompt's affordance text and the
fallback governance names drop it; the shared `pulse_question` declaration in
`agentcapabilities` is unchanged. Patrol investigations, provider routing, model-boundary
sanitizing, approvals, and the Pulse Mobile chat contract are unchanged.
Proof: `agentic_question_tool_test.go` and the interaction scenario corpus.
@@ -1230,25 +1230,16 @@ surface-contract block from `Manifest.SurfaceContract` and the MCP client
config block from `Manifest.MCPAdapter`; scope, tool, prompt, error-code, and
Assistant/MCP/Core/Patrol relationship docs are all API-manifest projections,
not parallel onboarding snapshots.
Native Assistant workflow-prompt rendering is a canonical API projection of
that same manifest. `POST /api/ai/workflow-prompts/render` is an authenticated
`ai:chat` route owned by `internal/api/ai_handler.go` and
`internal/api/router_routes_ai_relay.go`; it accepts the manifest prompt name
plus argument map, validates them against the manifest-owned workflow prompt
catalogue through `internal/agentcapabilities`, and returns only the rendered
Assistant prompt text plus description. Clients must read prompt names,
display labels, presentation kind hints, descriptions, and arguments through
the generated `workflowPrompts` manifest type and call this route rather than
rendering prompt templates locally. No first-party browser surface renders
starters since the Assistant chat window was retired on 2026-10-22.
MCP `prompts/list` projects those same labels as protocol prompt titles and MCP
`prompts/get` remains the protocol-edge projection over the same shared
workflow-prompt core. Route inventory and AI chat scope tests must pin that
route so it does not drift into public manifest discovery, relay-mobile access,
or `ai:execute` approval replay.
Workflow-prompt rendering is a canonical API projection of that same
manifest. MCP `prompts/list` projects the manifest labels as protocol prompt
titles and MCP `prompts/get` renders through the shared workflow-prompt core
in `internal/agentcapabilities`. The authenticated
`POST /api/ai/workflow-prompts/render` route that rendered starters for the
browser Assistant was removed on 2026-10-23, after the chat window that
called it was retired; no client may render prompt templates locally.
Successful workflow prompt rendering is also the API boundary for content-free
guided-starter activity: native Assistant rendering records only the manifest
prompt name, coarse Assistant surface, and timestamp; first-party Pulse app
guided-starter activity: activity records carry only the manifest prompt
name, coarse surface, and timestamp; first-party Pulse app
surfaces that start the same governed journey without rendering prompt text may
record through authenticated `POST /api/ai/workflow-prompts/activity`; and MCP
adapters that render prompts locally may report the same event through
@@ -1443,11 +1434,10 @@ belong in `frontend-modern/src/api/agentCapabilities.ts`, so native Assistant
UI, MCP onboarding surfaces, and future agent-compatible surfaces do not infer
tool availability from local tool-name lists. Do not expose live Assistant
registry availability through the public, cacheable `/api/agent/capabilities`
manifest; that runtime inventory belongs behind the authenticated
`GET /api/ai/assistant/surface-tools` Assistant surface endpoint when the UI
needs it. A client that renders runtime tool posture must read that endpoint and
format it through `getAgentSurfaceToolPosturePresentation` instead of
hard-coding registry tool names or treating MCP capability names as native
manifest; the authenticated `GET /api/ai/assistant/surface-tools` endpoint that served
it to the browser Assistant was removed on 2026-10-23 with the retired chat
window, and no client renders runtime Assistant tool posture. Clients must
not hard-code registry tool names or treat MCP capability names as native
Assistant tools. The Agent
integrations panel may render MCP tool posture from the static public manifest,
but the request/response capability filtering and `SurfaceToolContract`
@@ -1504,18 +1494,9 @@ retry fields, but `provider_fallback`, `failed_provider`, `failed_model`,
`next_provider`, and `next_model` are retired from the stream event
contract. Cross-route recovery belongs to explicit failed-turn actions, not
hidden `/api/ai/chat` stream mutation.
Assistant session summarization is a typed compaction API contract, not a
free-form helper response. `POST /api/ai/sessions/{id}/summarize` returns a
browser-safe `ChatSessionCompactionResult` with stable fields for `success`,
`status`, `message`, `session_id`, `summary_message_id`,
`original_message_count`, `compacted_message_count`, `compacted_messages`,
`kept_recent_messages`, and `summary_chars`. Short sessions may return
`success=false` with `status=not_needed`; transport failures, missing sessions,
provider errors, or active-running sessions remain normal API errors. The
payload must not include the raw transcript, raw provider response, raw tool
input/output, hidden reasoning, secrets, or token values. Clients must
preserve that typed response and reload canonical session state instead of
rebuilding a compacted transcript locally.
Assistant session summarization (`POST /api/ai/sessions/{id}/summarize` and
its `ChatSessionCompactionResult`) was removed on 2026-10-23 with the browser
Assistant, its only caller.
Infrastructure settings copied agent commands are a shared API/payload boundary
even when the final command string is assembled in the browser. Selected-row
@@ -2287,68 +2268,29 @@ payload shape change when the portal presents compact client rows.
browser-safe `ChatSession` projection. Rename must not expose or mutate
stored prompts, messages, provider reasoning, model handoff context,
approvals, action state, or tool evidence.
`POST /api/ai/sessions/{id}/undo` and
`POST /api/ai/sessions/{id}/redo` own the Assistant turn-repair API
contract. Undo removes the latest user-authored turn and all later
assistant/tool messages as one durable unit, returns a browser-safe
`restored_prompt`, `removed_messages`, and `can_redo`, and must not expose
provider reasoning, raw tool output, model-only handoff text, approval
payload internals, or remediation command data. Redo restores the latest
undone turn and returns `restored_messages` plus the remaining `can_redo`
state. `GET /api/ai/sessions` and other `ChatSession` projections may
expose only the boolean `can_redo` hint alongside safe title/timestamp,
count, and handoff-summary fields so a client can re-enable redo after a
reload without reading the redo stack itself. The projection also carries
a boolean `system` flag for Pulse-owned background sessions (Patrol
detection `patrol-main`, `patrol-eval`, and `investigation-*` runs),
derived server-side from the well-known session IDs via
`chat.IsSystemSessionID`; browser clients must not re-derive that
ownership from ID string patterns, and must keep system sessions out of
resumable-chat offers (quick-resume) while leaving them listable for
inspection. The undo endpoint accepts an optional JSON body
(`chat.SessionTurnUndoOptions`) whose `expected_prompt` field makes the
removal conditional on the latest user-authored prompt matching the
prompt being re-run; retry/regenerate flows send it so a stale retry can
never remove a different turn, and a guard mismatch returns
`success:false` with a browser-safe message rather than an error status.
An absent or empty body preserves the unguarded undo shape.
`POST /api/ai/sessions/{id}/steer` owns the mid-turn steering API
contract (`chat.SessionSteerRequest` -> `chat.SessionSteerResult`). The
request carries `prompt` plus an optional `client_message_id`; the
response is immediate JSON, never a second SSE stream, and
`accepted:false` with a `reason` (`no_active_run`, `system_session`,
`empty_prompt`, `steer_backlog` when the bounded per-session steering
inbox is full) is a normal outcome that clients handle by keeping the
follow-up on the ordinary queue-drain path. Steering cannot carry model
route, control level, or autonomous-mode changes. Confirmation of
delivery arrives only on the session's existing chat stream as a
`steer_applied` event (`chat.SteerAppliedData`) echoing
`client_message_id`, the server message id, the prompt, and the turn
index; clients must treat the endpoint's `accepted:true` as inbox
receipt, not delivery.
Every Assistant session mutation sub-resource under
`/api/ai/sessions/{id}` (`/abort`, `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`) accepts only `POST`. Any other method that reaches the
The turn-repair, compaction, fork, and steering sub-resources (`/undo`,
`/redo`, `/summarize`, `/fork`, `/steer`) and the unsupported OpenCode-style
`/diff`, `/revert`, and `/unrevert` routes were removed on 2026-10-23 with
the browser Assistant that called them; Pulse Mobile never used them.
They fall to the session router's unknown-subresource `404` after its
`ai:chat` scope check, and `ChatSession` projections no longer carry
`can_redo`. The projection carries a boolean `system` flag for Pulse-owned
background sessions (Patrol detection `patrol-main`, `patrol-eval`, and
`investigation-*` runs), derived server-side from the well-known session
IDs via `chat.IsSystemSessionID`; clients must not re-derive that ownership
from ID string patterns, and must keep system sessions out of
resumable-chat offers while leaving them listable for inspection.
`POST /api/ai/sessions/{id}/abort` is the remaining session mutation
sub-resource and accepts only `POST`. Any other method that reaches the
handler returns `405` with `Allow: POST` through
`requireAssistantSessionMutationMethod` in `internal/api/ai_handler.go`,
before the session service is resolved (earlier layers still answer
first: `Router.ServeHTTP` returns `200` for `OPTIONS` preflight, and
auth, scope, CSRF, and demo-mode guards keep their own refusals). The
session router dispatches these sub-resources by path alone, and the
demo-mode read-only guard and the CSRF check both treat `GET` and `HEAD`
as reads, so the method contract belongs to the mutating handler rather
than to its callers. `GET /api/ai/sessions/{id}/messages` stays the read
path, and the unsupported `/diff`, `/revert`, and `/unrevert` handlers
keep answering `501` for every method that reaches them.
OpenCode-style file diff/revert session routes are deliberately not part
of Pulse's supported Assistant session contract: Pulse sessions do not own
local code-file edits, and infrastructure mutations must be reviewed
through governed approval/action history. Browser clients must not expose
or call `GET /api/ai/sessions/{id}/diff`,
`POST /api/ai/sessions/{id}/revert`, or
`POST /api/ai/sessions/{id}/unrevert`; legacy direct calls to those routes
return `501 Not Implemented` with an explicit unsupported message rather
than a placeholder success payload.
path.
Resource-context follow-up turns are different from Patrol-run rehydration:
browser-safe `handoff_metadata.kind=resource_context` must not replace a
stored rich handoff envelope with a partial metadata-only envelope, and the
@@ -12544,3 +12486,18 @@ process recorded refuses neither. The adapter takes each run's start from the
orchestrator's session store, so the Pulse–enterprise boundary is unchanged: no
`aicontracts` type, field or method is added, and no route or payload changes.
Proof: `ai_handlers_investigation_additional_test.go`.
The public API drops the routes that only the retired browser chat window
called (2026-10-23): `/api/ai/assistant/surface-tools`, `/api/ai/workflow-prompts/render`,
`/api/ai/question/{id}/answer`, and the `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`, `/diff`, `/revert`, and `/unrevert` session sub-resources. Removed session sub-resources fall to the
session router's unknown-subresource `404` after its `ai:chat` scope check,
and `ChatSession` projections no longer carry `can_redo`. `docs/API.md` lists
the remaining chat routes under "Chat Sessions (Pulse Mobile)", including the
rename route Pulse Mobile already used. The `/api/agent/capabilities` manifest's
Pulse Assistant surface no longer declares the `interactiveQuestions`
affordance, and the generated `docs/AI.md` and `cmd/pulse-mcp/README.md`
surface blocks follow it. `/api/ai/status` stays for the dev
health check. Proof: `route_inventory_test.go`,
`router_routes_additional_test.go`, `security_regression_test.go`, and
`frontend-modern/src/utils/__tests__/docsLinks.test.ts`.
@@ -1100,15 +1100,8 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
grant signing and WebSocket writes happen only after that bounded verifier
succeeds.
Assistant mid-turn steering (`POST /api/ai/sessions/{id}/steer`, routed
through the session sub-route dispatch in `internal/api/router.go`) is a
point operation on the same terms: a map lookup of the session's active
loop plus an in-memory inbox append, returning immediate JSON with no
second SSE stream, no session-file read, and no provider work on the
request path. The per-session steering inbox is bounded
(`maxPendingSteersPerSession`), so repeated steers cannot grow service
memory or the running turn's prompt without limit; overflow returns
`steer_backlog` and the message stays on the client's queue.
Assistant mid-turn steering was removed on 2026-10-23 with the browser
chat window, its only caller.
Scheduled-report background worker registration is allowed in router startup,
but it must stay outside protected request handling. Due-schedule scans may
enumerate tenant organization IDs and load each workspace schedule store, but
@@ -1219,12 +1212,8 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
capability check, but router setup and auth gating must not scan session
files, load transcripts, hydrate handoff context, or inspect model/tool
history before the route-local handler owns the single title mutation.
Assistant session undo/redo routing follows the same bounded router rule:
`POST /api/ai/sessions/{id}/undo` and
`POST /api/ai/sessions/{id}/redo` may branch to the AI handler after normal
auth/scope checks, but router setup must not scan sessions, read redo stacks,
hydrate transcripts, or perform model/provider work before the route-local
handler owns the single session mutation.
Assistant session undo/redo was removed on 2026-10-23 with the browser
chat window.
Report branding follows the same bounded-work rule. Reporting handlers may
read provider-default env configuration and a tenant-local system-settings
record when a report is generated, but router setup and normal protected
@@ -3658,3 +3658,12 @@ read-only projection of the existing RequireAdmin + `ai:execute` gate on
`POST /api/ai/patrol/run`. It grants nothing: the route still enforces its own
gate, and the field only stops the browser offering alert Investigate to
sessions that route would refuse.
Removing the browser-only chat routes on 2026-10-23 (`/api/ai/assistant/surface-tools`, `/api/ai/workflow-prompts/render`,
`/api/ai/question/{id}/answer`, and the `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`, `/diff`, `/revert`, and `/unrevert` session sub-resources) narrows the
`ai:chat` surface and adds no access. The removed session sub-resources fall to
the session router's default branch, which still refuses tokens without
`ai:chat` before answering `404`
(`TestRelayMobileAccessScopeDeniesAdjacentAIRoutes`). The undo, redo, and steer
notes above are history.
@@ -7179,3 +7179,12 @@ The `internal/api` change that makes the investigation orchestrator's finding
writes check run ownership opens no storage, backup or recovery path. It decides
only whether a run's start or result is recorded on a Patrol finding. Recovery
evidence, restore rules and storage capabilities are unchanged.
The `internal/api` and `internal/ai/chat` change of 2026-10-23 removes
browser-only chat routes (`/api/ai/assistant/surface-tools`, `/api/ai/workflow-prompts/render`,
`/api/ai/question/{id}/answer`, and the `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`, `/diff`, `/revert`, and `/unrevert` session sub-resources). The adjacent-boundary notes above that
name those routes are now history. Persisted chat session files no longer
carry `turn_redo_stack` or the `steered` message flag; older files that still
have them load unchanged and drop them on the next write. No backup, restore,
or recovery path changes.
+1 -1
View File
@@ -12,7 +12,7 @@ Pulse Intelligence is built around a shared **Pulse Intelligence Core**: Canonic
That core is deliberately surfaced with Patrol as the primary built-in operator and Assistant plus MCP as access paths over the same governed capabilities:
1. **Pulse Patrol**: Patrol is the first-party operations surface: it checks infrastructure, investigates issues, follows the chosen Patrol mode before acting, verifies outcomes, and records what happened.
2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.
2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.
3. **Pulse MCP**: The external-agent adapter that projects canonical Pulse Intelligence capabilities as MCP tools. Affordances: tools, resources, prompts, and capability metadata.
<!-- pulse-intelligence-overview:end -->
+8 -13
View File
@@ -1548,25 +1548,20 @@ Runs an AI request which may return tool calls, findings, or suggested actions.
### Execute (Streaming)
`POST /api/ai/execute/stream`
Streaming variant of execute (used by the UI for incremental responses).
Streaming variant of execute.
### Assistant Chat & Sessions
- `GET /api/ai/status`
### Chat Sessions (Pulse Mobile)
The web chat window is retired; these routes serve Pulse Mobile chat until
the app retires on 31 March 2027. To ask about your infrastructure from an
agent, connect an MCP client through `pulse-mcp` instead.
- `GET /api/ai/status` reports whether the chat service is running
- `POST /api/ai/chat` (streaming)
- `GET /api/ai/sessions` returns session summaries; scoped Assistant handoffs may include a safe `handoff_summary` marker without model-only context text or command payloads.
- `GET /api/ai/sessions` returns session summaries; scoped handoffs may include a safe `handoff_summary` marker without model-only context text or command payloads.
- `POST /api/ai/sessions`
- `PATCH /api/ai/sessions/{id}` (rename)
- `DELETE /api/ai/sessions/{id}`
- `GET /api/ai/sessions/{id}/messages`
- `POST /api/ai/sessions/{id}/abort`
- `POST /api/ai/sessions/{id}/summarize`
- `POST /api/ai/sessions/{id}/fork`
- Legacy OpenCode-style file-change routes (`GET /api/ai/sessions/{id}/diff`,
`POST /api/ai/sessions/{id}/revert`, `POST /api/ai/sessions/{id}/unrevert`)
return `501 Not Implemented`; Pulse Assistant sessions do not own file diffs
or file-level revert.
### Question Answers
- `POST /api/ai/question/{id}/answer`
### Kubernetes AI Analysis (Compatibility)
`POST /api/ai/kubernetes/analyze`
@@ -781,6 +781,32 @@ describe('docsLinks', () => {
expect(probe).toContain('Empty-body requests keep the saved TLS settings');
});
it('ships only the chat session routes Pulse Mobile still uses', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shipped = readFileSync(path.join(frontendRoot, 'public', 'docs', 'API.md'), 'utf8');
const sessions = apiReference.split('### Chat Sessions (Pulse Mobile)')[1].split('\n### ')[0];
expect(shipped).toBe(apiReference);
expect(sessions).toContain('these routes serve Pulse Mobile chat');
expect(sessions).toContain('`POST /api/ai/chat` (streaming)');
expect(sessions).toContain('`PATCH /api/ai/sessions/{id}` (rename)');
expect(sessions).toContain('`POST /api/ai/sessions/{id}/abort`');
// The web chat window that called these is retired.
for (const retired of [
'/summarize',
'/fork',
'/undo',
'/redo',
'/steer',
'/revert',
'/api/ai/question/',
'/api/ai/assistant/surface-tools',
'/api/ai/workflow-prompts/render',
]) {
expect(apiReference).not.toContain(retired);
}
});
it('ships the per-alert snooze and resume API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
@@ -224,10 +224,10 @@ func TestBuildPulseAssistantOperatingInstructionsNamesOnlyAssistantAffordances(t
for _, expected := range []string{
"This surface is Pulse Assistant.",
"Use offered tools and interactive questions as the source of truth",
"Use offered tools as the source of truth",
"Pulse Assistant is the native Pulse surface over Pulse Intelligence Core",
"Pulse MCP is the external-agent adapter on the same core",
"Surface affordances: Pulse Assistant exposes tools and interactive questions.",
"Surface affordances: Pulse Assistant exposes tools.",
"Pulse Patrol is the primary built-in operator on Pulse Intelligence Core",
"governed infrastructure-operations surface",
"plan, approval, and execute flow",
+3 -2
View File
@@ -726,9 +726,10 @@ var canonicalManifest = Manifest{
Description: "The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions.",
Native: true,
ExternalAdapter: false,
// No interactive questions: no Pulse client can answer
// pulse_question since the web chat window was retired.
Affordances: SurfaceAffordanceContract{
Tools: true,
InteractiveQuestions: true,
Tools: true,
},
},
{
+2 -1
View File
@@ -342,7 +342,8 @@ func TestCanonicalManifestPinsPulseIntelligenceSurfaceContract(t *testing.T) {
!strings.Contains(assistant.Description, "Patrol findings") {
t.Fatalf("Pulse Assistant description must preserve contextual access-path role, got %q", assistant.Description)
}
if !assistant.Affordances.Tools || !assistant.Affordances.InteractiveQuestions ||
// No interactive questions: no Pulse client can answer pulse_question.
if !assistant.Affordances.Tools || assistant.Affordances.InteractiveQuestions ||
assistant.Affordances.Resources || assistant.Affordances.Prompts || assistant.Affordances.CapabilityMetadata {
t.Fatalf("Pulse Assistant affordance contract = %+v", assistant.Affordances)
}
+2 -2
View File
@@ -93,7 +93,7 @@ func TestPulseIntelligenceOverviewMarkdownProjectsManifestSurfaceContract(t *tes
"shared **Pulse Intelligence Core**: Canonical context, governed actions, safety gates, approval state, action audit, and verification shared by Pulse Assistant, Pulse MCP, and Pulse Patrol.",
"Patrol as the primary built-in operator and Assistant plus MCP as access paths",
"1. **Pulse Patrol**: Patrol is the first-party operations surface: it checks infrastructure, investigates issues, follows the chosen Patrol mode before acting, verifies outcomes, and records what happened.",
"2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.",
"2. **Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.",
"3. **Pulse MCP**: The external-agent adapter that projects canonical Pulse Intelligence capabilities as MCP tools. Affordances: tools, resources, prompts, and capability metadata.",
} {
if !strings.Contains(got, expected) {
@@ -122,7 +122,7 @@ func TestMCPSurfaceContractMarkdownProjectsManifestSurfaceContract(t *testing.T)
for _, expected := range []string{
"**Pulse Intelligence Core**: Canonical context, governed actions, safety gates, approval state, action audit, and verification shared by Pulse Assistant, Pulse MCP, and Pulse Patrol.",
"**Pulse Patrol**: Patrol is the first-party operations surface: it checks infrastructure, investigates issues, follows the chosen Patrol mode before acting, verifies outcomes, and records what happened.",
"**Pulse Assistant** (the native Pulse surface): The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.",
"**Pulse Assistant** (the native Pulse surface): The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.",
"**Pulse MCP**: The external-agent adapter that projects canonical Pulse Intelligence capabilities as MCP tools. Affordances: tools, resources, prompts, and capability metadata.",
} {
if !strings.Contains(got, expected) {
+18 -2
View File
@@ -297,8 +297,24 @@ func TestProjectPulseAssistantProviderToolsUsesSurfaceAffordances(t *testing.T)
canonical := ProjectPulseAssistantProviderTools(manifest, registryTools, governance, AssistantProviderToolOptions{
IncludeQuestionTool: true,
})
if names := ProviderToolNames(canonical); !reflect.DeepEqual(names, []string{PulseQueryToolName, PulseQuestionToolName}) {
t.Fatalf("canonical Assistant provider tools = %#v, want registry plus question", names)
// The canonical Assistant surface declares no interactive questions: no
// Pulse client can answer pulse_question since the web chat was retired.
if names := ProviderToolNames(canonical); !reflect.DeepEqual(names, []string{PulseQueryToolName}) {
t.Fatalf("canonical Assistant provider tools = %#v, want registry tools only", names)
}
questionsEnabled := manifest
questionsEnabled.SurfaceContract.OperatorSurfaces = append([]OperatorSurfaceContract(nil), manifest.SurfaceContract.OperatorSurfaces...)
for i := range questionsEnabled.SurfaceContract.OperatorSurfaces {
if questionsEnabled.SurfaceContract.OperatorSurfaces[i].ID == SurfaceIDPulseAssistant {
questionsEnabled.SurfaceContract.OperatorSurfaces[i].Affordances = SurfaceAffordanceContract{Tools: true, InteractiveQuestions: true}
}
}
interactive := ProjectPulseAssistantProviderTools(questionsEnabled, registryTools, governance, AssistantProviderToolOptions{
IncludeQuestionTool: true,
})
if names := ProviderToolNames(interactive); !reflect.DeepEqual(names, []string{PulseQueryToolName, PulseQuestionToolName}) {
t.Fatalf("questions-enabled Assistant provider tools = %#v, want registry plus question", names)
}
toolsDisabled := manifest
@@ -80,8 +80,7 @@ func DefaultSurfaceAffordancesForID(surfaceID string) SurfaceAffordanceContract
switch strings.TrimSpace(surfaceID) {
case SurfaceIDPulseAssistant:
return SurfaceAffordanceContract{
Tools: true,
InteractiveQuestions: true,
Tools: true,
}
case SurfaceIDPulseMCP:
return SurfaceAffordanceContract{
@@ -7,8 +7,8 @@ import (
func TestDefaultSurfaceAffordancesPinSupportedSurfaces(t *testing.T) {
assistant := DefaultSurfaceAffordancesForID(SurfaceIDPulseAssistant)
if !assistant.Tools || !assistant.InteractiveQuestions {
t.Fatalf("Pulse Assistant affordances = %+v, want tools and interactive questions", assistant)
if !assistant.Tools || assistant.InteractiveQuestions {
t.Fatalf("Pulse Assistant affordances = %+v, want tools only (no client can answer questions)", assistant)
}
if assistant.Resources || assistant.Prompts || assistant.CapabilityMetadata {
t.Fatalf("Pulse Assistant must not default to MCP-only affordances: %+v", assistant)
@@ -56,7 +56,7 @@ func TestResolveSurfaceAffordanceContractResolvesLabelsAndDefaults(t *testing.T)
if ok {
t.Fatal("legacy Assistant fallback must not report a declared surface")
}
if legacyAssistant.SurfaceLabel != "Pulse Assistant" || !legacyAssistant.Affordances.Tools || !legacyAssistant.Affordances.InteractiveQuestions {
if legacyAssistant.SurfaceLabel != "Pulse Assistant" || !legacyAssistant.Affordances.Tools || legacyAssistant.Affordances.InteractiveQuestions {
t.Fatalf("legacy Assistant fallback = %+v", legacyAssistant)
}
if legacyAssistant.Affordances.Resources || legacyAssistant.Affordances.Prompts || legacyAssistant.Affordances.CapabilityMetadata {
@@ -182,8 +182,21 @@ func TestCloneSurfaceToolContractsDetachesNames(t *testing.T) {
}
}
// assistantSurfaceWithQuestions re-enables interactive questions on the
// Pulse Assistant surface so projection tests can still cover native
// interaction tools; the canonical manifest no longer declares them.
func assistantSurfaceWithQuestions(contract SurfaceContract) SurfaceContract {
contract.OperatorSurfaces = append([]OperatorSurfaceContract(nil), contract.OperatorSurfaces...)
for i := range contract.OperatorSurfaces {
if contract.OperatorSurfaces[i].ID == SurfaceIDPulseAssistant {
contract.OperatorSurfaces[i].Affordances.InteractiveQuestions = true
}
}
return contract
}
func TestProjectPulseAssistantSurfaceToolContractSeparatesRegistryAndNativeTools(t *testing.T) {
contract, ok := ProjectPulseAssistantSurfaceToolContract(CanonicalManifest().SurfaceContract, []ProviderTool{
contract, ok := ProjectPulseAssistantSurfaceToolContract(assistantSurfaceWithQuestions(CanonicalManifest().SurfaceContract), []ProviderTool{
{Name: PulseQueryToolName},
{Name: PulseControlToolName},
NewPulseQuestionProviderTool(),
@@ -222,9 +235,12 @@ func TestProjectPulseAssistantSurfaceToolContractSeparatesRegistryAndNativeTools
if legacy.SurfaceID != SurfaceIDPulseAssistant || legacy.SurfaceLabel != "Pulse Assistant" {
t.Fatalf("legacy Assistant surface identity = %+v", legacy)
}
if !legacy.Affordances.Tools || !legacy.Affordances.InteractiveQuestions || legacy.Affordances.Resources {
if !legacy.Affordances.Tools || legacy.Affordances.InteractiveQuestions || legacy.Affordances.Resources {
t.Fatalf("legacy Assistant affordances = %+v", legacy.Affordances)
}
if len(legacy.NativeToolNames) != 0 {
t.Fatalf("legacy Assistant defaults must not offer pulse_question: %#v", legacy.NativeToolNames)
}
}
func TestProjectPulseAssistantSurfaceToolContractHonorsSurfaceAffordances(t *testing.T) {
@@ -506,8 +522,8 @@ func TestProjectPulseIntelligenceSurfaceToolContractsPreservesManifestSurfaceOrd
if contracts[0].SurfaceID != SurfaceIDPulseAssistant || contracts[1].SurfaceID != SurfaceIDPulseMCP || contracts[2].SurfaceID != "pulse_cli_agent" {
t.Fatalf("surface contract order = %+v", contracts)
}
if !reflect.DeepEqual(contracts[0].ToolNames, []string{PulseQueryToolName, PulseQuestionToolName}) {
t.Fatalf("Assistant projected names = %#v", contracts[0].ToolNames)
if !reflect.DeepEqual(contracts[0].ToolNames, []string{PulseQueryToolName}) {
t.Fatalf("Assistant projected names = %#v (canonical Assistant declares no interactive questions)", contracts[0].ToolNames)
}
if !reflect.DeepEqual(contracts[1].ToolNames, []string{FleetContextCapabilityName}) {
t.Fatalf("MCP projected names = %#v", contracts[1].ToolNames)
+21 -147
View File
@@ -677,10 +677,8 @@ type AgenticLoop struct {
// State for ongoing executions
mu sync.Mutex
aborted map[string]bool // sessionID -> aborted
pendingQs map[string]chan []QuestionAnswer // questionID -> answer channel
pendingSteers map[string][]pendingSteer // sessionID -> steering messages awaiting the next turn boundary
autonomousMode bool // When true, don't wait for approvals (for investigations)
aborted map[string]bool // sessionID -> aborted
autonomousMode bool // When true, don't wait for approvals (for investigations)
// executionProfile is the core-owned request posture (interactive
// Assistant, Patrol detection, Patrol investigation). It owns
// non-interactive behavior and the prompt's execution-mode text.
@@ -703,9 +701,8 @@ type AgenticLoop struct {
// NewAgenticLoop creates a new agentic loop
func NewAgenticLoop(provider providers.StreamingProvider, executor *tools.PulseToolExecutor, systemPrompt string) *AgenticLoop {
providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{
IncludeQuestionTool: true,
})
// No profile offers pulse_question: no Pulse client can answer it.
providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{})
return &AgenticLoop{
provider: provider,
@@ -715,16 +712,12 @@ func NewAgenticLoop(provider providers.StreamingProvider, executor *tools.PulseT
maxTurns: MaxAgenticTurns,
orgID: approval.DefaultOrgID,
aborted: make(map[string]bool),
pendingQs: make(map[string]chan []QuestionAnswer),
pendingSteers: make(map[string][]pendingSteer),
}
}
// UpdateTools refreshes the tool list from the executor
func (a *AgenticLoop) UpdateTools() {
a.tools = a.executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{
IncludeQuestionTool: true,
})
a.tools = a.executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{})
}
// SetRequestSanitizer installs a model-bound request sanitizer. It is called
@@ -780,10 +773,6 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
a.mu.Lock()
delete(a.aborted, sessionID)
a.mu.Unlock()
// Unconsumed steers are dropped, never persisted: the client keeps
// its row queued until steer_applied confirms delivery, so an
// undelivered steer re-sends as a normal follow-up turn.
a.discardPendingSteers(sessionID)
}()
// Convert our messages to provider format
@@ -839,30 +828,6 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
streamIdleTimeout := a.streamIdleTimeout
a.mu.Unlock()
// === MID-TURN STEERING: inject queued user messages at the boundary ===
// Steers arrive via Service.SteerSession while a turn streams or its
// tools run; they join the conversation here, before the next model
// call, so the model reads them as ordinary user turns. Each one is
// announced on the stream so the drawer can settle its pending row.
for _, steer := range a.takePendingSteers(sessionID) {
msg := steer.message.NormalizeCollections()
providerMessages = append(providerMessages, convertToProviderMessages([]Message{msg})...)
resultMessages = append(resultMessages, msg)
if data, err := json.Marshal(SteerAppliedData{
SessionID: sessionID,
MessageID: msg.ID,
ClientMessageID: steer.clientMessageID,
Prompt: msg.Content,
Turn: turn,
}); err == nil {
callback(StreamEvent{Type: "steer_applied", Data: data})
}
log.Info().
Int("turn", turn).
Str("session_id", sessionID).
Msg("[AgenticLoop] Steering message injected at turn boundary")
}
// Record telemetry for loop iteration
if metrics := GetAIMetrics(); metrics != nil {
metrics.RecordAgenticIteration(providerName, modelName)
@@ -1200,7 +1165,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
case "tool_start":
if data, ok := event.Data.(providers.ToolStartEvent); ok {
// pulse_question is rendered as a dedicated "question" card; suppress tool UI events.
// pulse_question is never offered and is refused below; keep its call out of the tool UI.
if data.Name == pulseQuestionToolName {
return
}
@@ -1559,13 +1524,18 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
}
var pendingExec []pendingToolExec
// Non-interactive profiles (Patrol detection/investigation) cannot
// wait for user input. A fabricated pulse_question call must not
// reach the interactive-call-set path below - that path emits a
// "waiting for your answer" event and skips sibling tool calls.
// Instead, answer each question call with a non-interactive error
// and keep processing the other calls from the same provider turn.
if a.currentExecutionProfile().NonInteractive() {
// No Pulse client can answer pulse_question: the web chat that
// rendered question cards is retired, Pulse Mobile has no question
// UI, and Patrol runs are non-interactive. The tool is never
// offered, so a call to it is fabricated (often copied from older
// transcript history). Answer each one with a refusal and keep
// processing the other calls from the same provider turn, instead
// of waiting for an answer that cannot arrive.
{
questionRefusal := "pulse_question is unavailable: no one can answer it in this session. Ask the user in your reply text instead, or continue with the available evidence."
if a.currentExecutionProfile().NonInteractive() {
questionRefusal = "pulse_question is unavailable: this is a non-interactive run and no user can answer. Continue with the available evidence."
}
remaining := toolCalls[:0]
for _, tc := range toolCalls {
if tc.Name != pulseQuestionToolName {
@@ -1575,9 +1545,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
log.Warn().
Str("id", tc.ID).
Str("session_id", sessionID).
Msg("[AgenticLoop] Blocked pulse_question in non-interactive execution profile")
projection := newProviderToolResultContextProjection(tc.ID,
"pulse_question is unavailable: this is a non-interactive run and no user can answer. Continue with the available evidence.", true)
Msg("[AgenticLoop] Refused unanswerable pulse_question call")
projection := newProviderToolResultContextProjection(tc.ID, questionRefusal, true)
// Persist the refusal alongside the provider context: the
// assistant message above already recorded the question
// call, so the durable transcript must pair it with this
@@ -1595,108 +1564,13 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
}
toolCalls = remaining
if len(toolCalls) == 0 {
// Same next-turn bookkeeping as the interactive-set path.
// Every call was refused: the next turn starts with the refusals.
currentTurnStartIndex = len(providerMessages)
turn++
continue
}
}
// pulse_question is interactive and must not run in parallel with other tools.
// If the provider emits multiple tool calls alongside pulse_question, skip the
// others and let the model retry after receiving the user's answer.
hasPulseQuestion := false
for _, tc := range toolCalls {
if tc.Name == pulseQuestionToolName {
hasPulseQuestion = true
break
}
}
if hasPulseQuestion {
emitWorkflowState(callback, "clarify", "Waiting for your answer before continuing.", "", pulseQuestionToolName)
for _, tc := range toolCalls {
log.Debug().
Str("tool", tc.Name).
Str("id", tc.ID).
Msg("Processing interactive tool call set (pulse_question present)")
if blockMsg, blocked := a.currentResourcePlaceholderBlock(tc); blocked {
log.Warn().
Str("tool", tc.Name).
Str("id", tc.ID).
Str("reason", blockMsg).
Msg("[AgenticLoop] Blocked invalid current_resource tool call before interactive-set execution")
emitCurrentResourceBlock(tc, blockMsg)
projection := newProviderToolResultContextProjection(tc.ID, blockMsg, true)
providerMessages = append(providerMessages, providers.Message{
Role: "user",
ToolResult: &projection.Model,
})
continue
}
// Skip non-question tools in this turn; the model must retry after user input.
if tc.Name != pulseQuestionToolName {
skipMsg := fmt.Sprintf("SKIPPED: %s was requested this turn. Wait for the user's answer, then re-issue this tool call with the clarified inputs.", pulseQuestionToolName)
emitToolStartIfNeeded(tc)
emitToolEndEvent(callback, tc.ID, tc.Name, tc.Input, skipMsg, false)
projection := newProviderToolResultContextProjection(tc.ID, skipMsg, true)
toolResultMsg := Message{
ID: uuid.New().String(),
Role: "user",
Timestamp: time.Now(),
ToolResult: &projection.Transcript,
}
resultMessages = append(resultMessages, toolResultMsg)
providerMessages = append(providerMessages, providers.Message{
Role: "user",
ToolResult: &projection.Model,
})
continue
}
// Execute the interactive question tool synchronously (blocks until user answers).
resultText, isError := a.executeQuestionTool(ctx, sessionID, tc, callback)
// For pulse_question we suppress tool_end on success (UI uses the question card).
if isError {
inputStr, rawInput := formatToolInputForFrontend(tc.Name, tc.Input, true)
jsonData, _ := json.Marshal(ToolEndData{
ID: tc.ID,
Name: tc.Name,
Input: inputStr,
RawInput: rawInput,
Output: resultText,
Success: false,
})
callback(StreamEvent{Type: "tool_end", Data: jsonData})
}
projection := newProviderToolResultContextProjection(tc.ID, resultText, isError)
toolResultMsg := Message{
ID: uuid.New().String(),
Role: "user",
Timestamp: time.Now(),
ToolResult: &projection.Transcript,
}
resultMessages = append(resultMessages, toolResultMsg)
providerMessages = append(providerMessages, providers.Message{
Role: "user",
ToolResult: &projection.Model,
})
}
// Mark the start of the next turn's messages for compaction tracking.
currentTurnStartIndex = len(providerMessages)
turn++
continue
}
for _, tc := range toolCalls {
log.Debug().
Str("tool", tc.Name).
-103
View File
@@ -1,103 +0,0 @@
package chat
// Clarification timing belongs to the model. The harness owns delivery and response pairing.
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/stretchr/testify/require"
)
func lookGateQuestionInput() map[string]interface{} {
return map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1", "type": "select",
"question": "Which resource do you want to check?",
"options": []interface{}{map[string]interface{}{"label": "Fleet", "value": "fleet"}},
},
},
}
}
func TestAgenticLoop_AllowsModelClarificationBeforeAnyRead(t *testing.T) {
turn := 0
provider := &stubStreamingProvider{}
provider.chatStream = func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
turn++
if turn == 1 {
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{
{ID: "q-" + string(rune('0'+turn)), Name: pulseQuestionToolName, Input: lookGateQuestionInput()},
},
}})
return nil
}
callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "Understood."}})
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
return nil
}
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: &mockStateProvider{},
AgentServer: &mockAgentServer{},
})
loop := NewAgenticLoop(provider, exec, "base prompt")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var (
mu sync.Mutex
questionEvt *QuestionData
)
doneCh := make(chan struct{})
var execErr error
go func() {
defer close(doneCh)
_, execErr = loop.ExecuteWithTools(
ctx,
"look-gate-failopen-session",
[]Message{{Role: "user", Content: "help me decide"}},
nil,
func(event StreamEvent) {
if event.Type != "question" {
return
}
mu.Lock()
defer mu.Unlock()
if questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
},
)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 3*time.Second, 10*time.Millisecond,
"the model-selected question must reach the user on its first attempt")
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, loop.AnswerQuestion(qID, []QuestionAnswer{{ID: "q1", Value: "fleet"}}))
select {
case <-doneCh:
case <-ctx.Done():
t.Fatalf("loop did not complete: %v", ctx.Err())
}
require.NoError(t, execErr)
require.Equal(t, 2, turn)
}
-109
View File
@@ -1,116 +1,7 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rs/zerolog/log"
)
const pulseQuestionToolName = agentcapabilities.PulseQuestionToolName
func parseQuestionToolInput(input map[string]interface{}) ([]Question, error) {
parsedQuestions, err := agentcapabilities.ParsePulseQuestionToolInput(input)
if err != nil {
return nil, err
}
questions := make([]Question, 0, len(parsedQuestions))
for _, parsedQuestion := range parsedQuestions {
options := make([]QuestionOption, 0, len(parsedQuestion.Options))
for _, parsedOption := range parsedQuestion.Options {
option := QuestionOption{
Label: parsedOption.Label,
Value: parsedOption.Value,
}
if parsedOption.Description != "" {
option.Description = parsedOption.Description
}
options = append(options, option)
}
question := Question{
ID: parsedQuestion.ID,
Type: string(parsedQuestion.Type),
Question: parsedQuestion.Question,
Options: options,
}
if parsedQuestion.Header != "" {
question.Header = parsedQuestion.Header
}
questions = append(questions, question)
}
return questions, nil
}
func (a *AgenticLoop) executeQuestionTool(ctx context.Context, sessionID string, tc providers.ToolCall, callback StreamCallback) (string, bool) {
a.mu.Lock()
autonomous := a.autonomousMode
a.mu.Unlock()
if autonomous {
return "Error: pulse_question cannot be used in autonomous mode (no interactive user available). Proceed with safe defaults or ask the user in plain text.", true
}
questions, err := parseQuestionToolInput(tc.Input)
if err != nil {
return fmt.Sprintf("Error: invalid pulse_question input: %v", err), true
}
questionID := uuid.New().String()
ch := make(chan []QuestionAnswer, 1)
a.mu.Lock()
a.pendingQs[questionID] = ch
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.pendingQs, questionID)
a.mu.Unlock()
}()
jsonData, _ := json.Marshal(QuestionData{
SessionID: sessionID,
QuestionID: questionID,
Questions: questions,
})
callback(StreamEvent{Type: "question", Data: jsonData})
log.Info().
Str("session_id", sessionID).
Str("question_id", questionID).
Int("questions", len(questions)).
Msg("[AgenticLoop] Waiting for user to answer pulse_question")
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Sprintf("Error: question canceled: %v", ctx.Err()), true
case answers := <-ch:
payload := map[string]interface{}{
"question_id": questionID,
"answers": answers,
}
out, err := json.Marshal(payload)
if err != nil {
return fmt.Sprintf("Error: failed to encode answers: %v", err), true
}
return string(out), false
case <-ticker.C:
a.mu.Lock()
aborted := a.aborted[sessionID]
a.mu.Unlock()
if aborted {
return "Error: question canceled: session aborted", true
}
}
}
}
@@ -1,53 +0,0 @@
package chat
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseQuestionToolInputMapsSharedQuestionPayloadToChatQuestions(t *testing.T) {
input := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": " q1 ",
"question": " Pick one ",
"options": []interface{}{
map[string]interface{}{
"label": " A ",
"value": "",
"description": " Alpha ",
},
},
},
map[string]interface{}{
"id": "q2",
"type": " TEXT ",
"question": " Why? ",
"header": " Context ",
},
},
}
questions, err := parseQuestionToolInput(input)
require.NoError(t, err)
require.Len(t, questions, 2)
assert.Equal(t, "q1", questions[0].ID)
assert.Equal(t, string(agentcapabilities.PulseQuestionToolTypeSelect), questions[0].Type)
assert.Equal(t, "Pick one", questions[0].Question)
require.Len(t, questions[0].Options, 1)
assert.Equal(t, QuestionOption{Label: "A", Value: "A", Description: "Alpha"}, questions[0].Options[0])
assert.Equal(t, "q2", questions[1].ID)
assert.Equal(t, string(agentcapabilities.PulseQuestionToolTypeText), questions[1].Type)
assert.Equal(t, "Why?", questions[1].Question)
assert.Equal(t, "Context", questions[1].Header)
}
func TestParseQuestionToolInputReturnsSharedParserErrors(t *testing.T) {
_, err := parseQuestionToolInput(map[string]interface{}{})
require.EqualError(t, err, "missing required field: questions")
}
+52 -124
View File
@@ -14,27 +14,6 @@ import (
"github.com/stretchr/testify/require"
)
// scriptLookTurn scripts one provider turn that issues a read-only
// pulse_query call. The look-before-asking gate refuses a pulse_question
// issued before any tool attempt in the run, so question-flow tests open
// with a look turn the way real runs now must.
func scriptLookTurn(mockProvider *MockProvider, matcher func(req providers.ChatRequest) bool) {
queryInput := map[string]interface{}{"action": "topology"}
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(matcher), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t0", Name: "pulse_query", Input: queryInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t0", Name: "pulse_query", Input: queryInput}},
},
})
}).Once()
}
// hasToolResult reports whether the request carries a tool result for the
// given provider tool-call ID.
func hasToolResult(req providers.ChatRequest, toolUseID string) bool {
@@ -46,7 +25,11 @@ func hasToolResult(req providers.ChatRequest, toolUseID string) bool {
return false
}
func TestAgenticLoop_PulseQuestionToolInteractiveFlow(t *testing.T) {
// No Pulse client can answer pulse_question since the web chat was retired:
// Pulse Mobile has no question UI. An interactive turn must therefore pair a
// question call with a refusal that sends the model back to plain reply text,
// and finish, instead of waiting for an answer that cannot arrive.
func TestAgenticLoop_RefusesPulseQuestionInInteractiveSessions(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "You are a helper")
@@ -54,83 +37,52 @@ func TestAgenticLoop_PulseQuestionToolInteractiveFlow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionID := "question-session"
messages := []Message{{Role: "user", Content: "Do something but ask me first"}}
questionInput := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
map[string]interface{}{"label": "B", "value": "b"},
},
},
},
}
// Turn 1: model looks with a read tool (satisfies the gate).
scriptLookTurn(mockProvider, func(req providers.ChatRequest) bool {
return len(req.Messages) == 1
})
queryInput := map[string]interface{}{"action": "topology"}
// Turn 2: model requests pulse_question
// Turn 1: the model calls pulse_question alongside a read tool.
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
return hasToolResult(req, "t0") && !hasToolResult(req, "t1")
return !hasToolResult(req, "t1")
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{
ID: "t1",
Name: pulseQuestionToolName,
Input: map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
map[string]interface{}{"label": "B", "value": "b"},
},
},
},
},
},
Data: providers.ToolStartEvent{ID: "t1", Name: pulseQuestionToolName, Input: questionInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{
{
ID: "t1",
Name: pulseQuestionToolName,
Input: map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
map[string]interface{}{"label": "B", "value": "b"},
},
},
},
},
},
{ID: "t1", Name: pulseQuestionToolName, Input: questionInput},
{ID: "t2", Name: "pulse_query", Input: queryInput},
},
},
})
}).Once()
// Turn 3: model sees the answered question and replies.
// Turn 2: the model reads the refusal and the sibling result, then asks
// in its reply instead.
var refusal string
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
// Expect tool result message for t1 to be present
if !hasToolResult(req, "t2") {
return false
}
for _, m := range req.Messages {
if m.ToolResult != nil && m.ToolResult.ToolUseID == "t1" {
var payload struct {
QuestionID string `json:"question_id"`
Answers []QuestionAnswer `json:"answers"`
}
if err := json.Unmarshal([]byte(m.ToolResult.Content), &payload); err != nil {
return false
}
if payload.QuestionID == "" || len(payload.Answers) != 1 {
return false
}
if payload.Answers[0].ID != "q1" || payload.Answers[0].Value != "b" {
return false
}
refusal = m.ToolResult.Content
return true
}
}
@@ -139,68 +91,44 @@ func TestAgenticLoop_PulseQuestionToolInteractiveFlow(t *testing.T) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "content",
Data: providers.ContentEvent{Text: "Thanks, you picked B."},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{},
Data: providers.ContentEvent{Text: "Which one do you want, A or B?"},
})
cb(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}).Once()
var (
mu sync.Mutex
events []StreamEvent
questionEvt *QuestionData
mu sync.Mutex
events []StreamEvent
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
events = append(events, event)
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
}
// Run loop in background; answer as soon as we see the question event.
var (
results []Message
err error
doneCh = make(chan struct{})
)
go func() {
defer close(doneCh)
results, err = loop.Execute(ctx, sessionID, messages, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 2*time.Second, 10*time.Millisecond, "expected question event")
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, loop.AnswerQuestion(qID, []QuestionAnswer{{ID: "q1", Value: "b"}}))
select {
case <-doneCh:
case <-ctx.Done():
t.Fatalf("agentic loop did not complete: %v", ctx.Err())
}
messages := []Message{{Role: "user", Content: "Do something but ask me first"}}
results, err := loop.Execute(ctx, "question-session", messages, callback)
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, "Thanks, you picked B.", results[len(results)-1].Content)
assert.Equal(t, "Which one do you want, A or B?", results[len(results)-1].Content)
assert.Contains(t, refusal, "pulse_question is unavailable")
assert.Contains(t, refusal, "Ask the user in your reply text")
// The durable transcript pairs the recorded question call with its
// refusal, so a reloaded session never holds an unanswered call.
paired := false
for _, msg := range results {
if msg.ToolResult != nil && msg.ToolResult.ToolUseID == "t1" {
paired = true
assert.True(t, msg.ToolResult.IsError)
}
}
assert.True(t, paired, "refusal must be persisted with the question call")
// Ensure pulse_question tool_start is suppressed (UI uses question card).
mu.Lock()
defer mu.Unlock()
for _, ev := range events {
assert.NotEqual(t, "question", ev.Type, "no client can render a question card")
if ev.Type == "tool_start" {
var ts ToolStartData
_ = json.Unmarshal(ev.Data, &ts)
-19
View File
@@ -8,25 +8,6 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
)
// AnswerQuestion provides an answer to a pending question.
func (a *AgenticLoop) AnswerQuestion(questionID string, answers []QuestionAnswer) error {
a.mu.Lock()
ch, exists := a.pendingQs[questionID]
a.mu.Unlock()
if !exists {
return fmt.Errorf("no pending question with ID: %s", questionID)
}
// Non-blocking send
select {
case ch <- answers:
return nil
default:
return fmt.Errorf("question already answered: %s", questionID)
}
}
// waitForApprovalDecision polls for an approval decision.
func waitForApprovalDecision(ctx context.Context, store *approval.Store, approvalID string, orgID string) (*approval.ApprovalRequest, error) {
ticker := time.NewTicker(500 * time.Millisecond)
-74
View File
@@ -1,74 +0,0 @@
package chat
import (
"errors"
"fmt"
"strings"
)
// maxPendingSteersPerSession bounds the steering inbox so a chatty client
// cannot grow the running turn's prompt (and the service's memory) without
// limit; excess steers stay on the client's ordinary follow-up queue.
const maxPendingSteersPerSession = 8
// errSteerBacklogFull reports a full steering inbox; the service maps it to
// a normal accepted=false outcome rather than an error status.
var errSteerBacklogFull = errors.New("steer backlog full")
// pendingSteer pairs the steering message with the client-side transcript
// row id so the steer_applied event can reconcile the originating drawer.
type pendingSteer struct {
message Message
clientMessageID string
}
// Steer queues a user message for injection into the running loop at the
// next turn boundary (the same checkpoint that observes aborts). Delivery is
// not guaranteed: if the run finishes before a boundary arrives, unconsumed
// steers are discarded and the client re-sends through the normal queue
// drain, so the message is never persisted twice.
func (a *AgenticLoop) Steer(sessionID string, msg Message, clientMessageID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return fmt.Errorf("steer requires a session id")
}
if strings.TrimSpace(msg.Content) == "" {
return fmt.Errorf("steer requires a non-empty prompt")
}
a.mu.Lock()
defer a.mu.Unlock()
if a.pendingSteers == nil {
a.pendingSteers = make(map[string][]pendingSteer)
}
if len(a.pendingSteers[sessionID]) >= maxPendingSteersPerSession {
return errSteerBacklogFull
}
a.pendingSteers[sessionID] = append(a.pendingSteers[sessionID], pendingSteer{
message: msg,
clientMessageID: strings.TrimSpace(clientMessageID),
})
return nil
}
// takePendingSteers drains and returns the steering messages queued for a
// session, in arrival order.
func (a *AgenticLoop) takePendingSteers(sessionID string) []pendingSteer {
a.mu.Lock()
defer a.mu.Unlock()
steers := a.pendingSteers[sessionID]
if len(steers) == 0 {
return nil
}
delete(a.pendingSteers, sessionID)
return steers
}
// discardPendingSteers drops any unconsumed steering messages when a run
// ends. The frontend keeps the row queued until steer_applied confirms
// delivery, so an undelivered steer drains as a normal follow-up turn.
func (a *AgenticLoop) discardPendingSteers(sessionID string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.pendingSteers, sessionID)
}
-375
View File
@@ -1,375 +0,0 @@
package chat
import (
"context"
"encoding/json"
"runtime"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestAgenticLoop_SteerInjectsAtNextTurnBoundary drives a two-turn run where
// turn 1 blocks on a pulse_question. A steer delivered while the run is
// blocked must be injected as a user message before turn 2's provider call,
// announced via steer_applied, and returned in resultMessages marked Steered.
func TestAgenticLoop_SteerInjectsAtNextTurnBoundary(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "You are a helper")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionID := "steer-session"
messages := []Message{{Role: "user", Content: "Do something but ask me first"}}
const steerPrompt = "actually check pve2 as well"
// Turn 1: model looks with a read tool (the look-before-asking gate
// refuses a first-action pulse_question).
scriptLookTurn(mockProvider, func(req providers.ChatRequest) bool {
return len(req.Messages) == 1
})
// Turn 2: model requests pulse_question (blocks until answered).
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
return hasToolResult(req, "t0") && !hasToolResult(req, "t1")
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
toolInput := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
},
},
},
}
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t1", Name: pulseQuestionToolName, Input: toolInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t1", Name: pulseQuestionToolName, Input: toolInput}},
},
})
}).Once()
// Final turn: the request must carry the steer as a user message AFTER
// the tool result for t1.
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
toolResultIndex := -1
steerIndex := -1
for i, m := range req.Messages {
if m.ToolResult != nil && m.ToolResult.ToolUseID == "t1" {
toolResultIndex = i
}
if m.Role == "user" && m.Content == steerPrompt {
steerIndex = i
}
}
return toolResultIndex >= 0 && steerIndex > toolResultIndex
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "Checking pve2 too."}})
cb(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}).Once()
var (
mu sync.Mutex
questionEvt *QuestionData
steerApplied *SteerAppliedData
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
if event.Type == "steer_applied" && steerApplied == nil {
var sd SteerAppliedData
_ = json.Unmarshal(event.Data, &sd)
steerApplied = &sd
}
}
var (
results []Message
err error
doneCh = make(chan struct{})
)
go func() {
defer close(doneCh)
results, err = loop.Execute(ctx, sessionID, messages, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 2*time.Second, 10*time.Millisecond, "expected question event")
// Steer while the run is blocked, then unblock it.
require.NoError(t, loop.Steer(sessionID, Message{
ID: "steer-msg-1", Role: "user", Content: steerPrompt, Steered: true, Timestamp: time.Now(),
}, "client-row-1"))
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, loop.AnswerQuestion(qID, []QuestionAnswer{{ID: "q1", Value: "a"}}))
select {
case <-doneCh:
case <-ctx.Done():
t.Fatalf("agentic loop did not complete: %v", ctx.Err())
}
require.NoError(t, err)
mu.Lock()
require.NotNil(t, steerApplied, "expected steer_applied event")
assert.Equal(t, "client-row-1", steerApplied.ClientMessageID)
assert.Equal(t, "steer-msg-1", steerApplied.MessageID)
assert.Equal(t, steerPrompt, steerApplied.Prompt)
mu.Unlock()
steeredCount := 0
for _, msg := range results {
if msg.Steered {
steeredCount++
assert.Equal(t, "user", msg.Role)
assert.Equal(t, steerPrompt, msg.Content)
}
}
assert.Equal(t, 1, steeredCount, "expected exactly one steered message in results")
assert.Equal(t, "Checking pve2 too.", results[len(results)-1].Content)
mockProvider.AssertExpectations(t)
}
// TestAgenticLoop_UnconsumedSteerIsDiscarded verifies that a steer arriving
// too late for any boundary is dropped when the run ends, so the client's
// queue-drain fallback cannot double-record it.
func TestAgenticLoop_UnconsumedSteerIsDiscarded(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
provider := &stubStreamingProvider{}
loop := NewAgenticLoop(provider, executor, "system")
sessionID := "late-steer-session"
_, err := loop.Execute(context.Background(), sessionID, []Message{{Role: "user", Content: "hi"}}, func(StreamEvent) {})
require.NoError(t, err)
// The run already ended; the loop's defer must have cleared the inbox,
// and a fresh run must not see stale steers from a prior run either.
require.NoError(t, loop.Steer(sessionID, Message{ID: "late", Role: "user", Content: "too late"}, ""))
loop.discardPendingSteers(sessionID)
assert.Empty(t, loop.takePendingSteers(sessionID))
}
// TestAgenticLoop_SteerBacklogIsBounded verifies the inbox cap so a chatty
// client cannot grow the running turn's prompt without limit.
func TestAgenticLoop_SteerBacklogIsBounded(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
loop := NewAgenticLoop(&stubStreamingProvider{}, executor, "system")
for i := 0; i < maxPendingSteersPerSession; i++ {
require.NoError(t, loop.Steer("backlog-session", Message{ID: "m", Role: "user", Content: "steer"}, ""))
}
err := loop.Steer("backlog-session", Message{ID: "m", Role: "user", Content: "one too many"}, "")
require.ErrorIs(t, err, errSteerBacklogFull)
assert.Len(t, loop.takePendingSteers("backlog-session"), maxPendingSteersPerSession)
}
// TestService_SteerSession_RoutingOutcomes covers the service-level routing
// results that never reach a loop.
func TestService_SteerSession_RoutingOutcomes(t *testing.T) {
svc := &Service{}
result, err := svc.SteerSession(context.Background(), "no-run-session", SessionSteerRequest{Prompt: "hello"})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "no_active_run", result.Reason)
result, err = svc.SteerSession(context.Background(), "patrol-main", SessionSteerRequest{Prompt: "hello"})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "system_session", result.Reason)
result, err = svc.SteerSession(context.Background(), "some-session", SessionSteerRequest{Prompt: " "})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "empty_prompt", result.Reason)
_, err = svc.SteerSession(context.Background(), "../bad", SessionSteerRequest{Prompt: "hello"})
require.Error(t, err)
}
// TestService_ExecuteStream_SteeredMessagePersists proves the end-to-end
// path: a steer accepted mid-run is injected at the boundary AND survives
// the end-of-run save (which skips ordinary user messages).
func TestService_ExecuteStream_SteeredMessagePersists(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewSessionStore(tmpDir)
require.NoError(t, err)
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "system")
svc := &Service{
cfg: &config.AIConfig{ChatModel: "openai:test"},
sessions: store,
executor: executor,
agenticLoop: loop,
provider: mockProvider,
started: true,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
}
const steerPrompt = "steer: also check the replication lag"
sessionID := "sess-steer-persist"
// Turn 1 looks with a read tool (the look-before-asking gate refuses a
// first-action pulse_question); turn 2 blocks on a question; the test
// steers through the SERVICE while blocked, then answers.
toolInput := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1", "type": "select", "question": "Proceed?",
"options": []interface{}{map[string]interface{}{"label": "Yes", "value": "y"}},
},
},
}
scriptLookTurn(mockProvider, func(req providers.ChatRequest) bool {
return !hasToolResult(req, "t0")
})
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
for _, m := range req.Messages {
if m.Role == "user" && m.Content == steerPrompt {
return false
}
}
return hasToolResult(req, "t0")
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t1", Name: pulseQuestionToolName, Input: toolInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t1", Name: pulseQuestionToolName, Input: toolInput}},
},
})
}).Once()
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
for _, m := range req.Messages {
if m.Role == "user" && m.Content == steerPrompt {
return true
}
}
return false
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "done, checked lag"}})
cb(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}).Once()
var (
mu sync.Mutex
questionEvt *QuestionData
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
}
var execErr error
doneCh := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
// A failed assertion must not remove the session directory while the
// streaming worker still writes it, or leave a question waiting forever.
// Keep the completion deadline below unchanged; on failure retain stacks
// instead of hiding a slow or blocked persistence path with a longer wait.
goroutineStacks := func() string {
buf := make([]byte, 64<<10)
n := runtime.Stack(buf, true)
return string(buf[:n])
}
t.Cleanup(func() {
cancel()
select {
case <-doneCh:
case <-time.After(5 * time.Second):
t.Errorf("ExecuteStream worker did not stop after cancellation:\n%s", goroutineStacks())
}
})
go func() {
defer close(doneCh)
execErr = svc.ExecuteStream(ctx, ExecuteRequest{SessionID: sessionID, Prompt: "check the cluster"}, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 3*time.Second, 10*time.Millisecond, "expected question event")
steerResult, err := svc.SteerSession(context.Background(), sessionID, SessionSteerRequest{
Prompt: steerPrompt,
ClientMessageID: "client-row-9",
})
require.NoError(t, err)
require.True(t, steerResult.Accepted, "expected steer to reach the active loop, got reason %q", steerResult.Reason)
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, svc.AnswerQuestion(context.Background(), qID, []QuestionAnswer{{ID: "q1", Value: "y"}}))
select {
case <-doneCh:
case <-time.After(5 * time.Second):
t.Fatalf("ExecuteStream did not complete:\n%s", goroutineStacks())
}
require.NoError(t, execErr)
// The steered message must be in durable history, after the opening
// prompt and before the final assistant answer.
saved, err := store.GetMessages(sessionID)
require.NoError(t, err)
steerIndex, finalIndex := -1, -1
for i, msg := range saved {
if msg.Steered && msg.Content == steerPrompt {
steerIndex = i
}
if msg.Role == "assistant" && msg.Content == "done, checked lag" {
finalIndex = i
}
}
require.GreaterOrEqual(t, steerIndex, 1, "steered message missing from durable history")
require.Greater(t, finalIndex, steerIndex, "final answer should follow the steered message")
mockProvider.AssertExpectations(t)
}
-35
View File
@@ -485,41 +485,6 @@ func TestAgenticLoop_SystemPromptPartsSeparateStablePrefix(t *testing.T) {
}
}
func TestAgenticLoop_AnswerQuestion(t *testing.T) {
mockProvider := &MockProvider{}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
loop := NewAgenticLoop(mockProvider, executor, "test")
t.Run("NoPendingQuestion", func(t *testing.T) {
err := loop.AnswerQuestion("missing", nil)
require.Error(t, err)
})
t.Run("AnswersDelivered", func(t *testing.T) {
ch := make(chan []QuestionAnswer, 1)
loop.pendingQs["q1"] = ch
answers := []QuestionAnswer{{ID: "q", Value: "a"}}
err := loop.AnswerQuestion("q1", answers)
require.NoError(t, err)
select {
case got := <-ch:
assert.Equal(t, answers, got)
default:
t.Fatal("expected answers to be delivered")
}
})
t.Run("AlreadyAnswered", func(t *testing.T) {
ch := make(chan []QuestionAnswer)
loop.pendingQs["q2"] = ch
err := loop.AnswerQuestion("q2", []QuestionAnswer{{ID: "q", Value: "a"}})
require.Error(t, err)
})
}
func TestWaitForApprovalDecision(t *testing.T) {
store, err := approval.NewStore(approval.StoreConfig{
DataDir: t.TempDir(),
@@ -100,12 +100,11 @@ type interactionScenario struct {
name string
// promise states the user-visible behavior this scenario pins, in plain
// language. If a change makes this scenario fail, that promise broke.
promise string
prompt string
calls []scriptedProviderCall
maxTurns int
wantErr bool
answerQuestion bool
promise string
prompt string
calls []scriptedProviderCall
maxTurns int
wantErr bool
// orderedTypes must appear in the recorded event stream as a
// subsequence (other events may interleave).
orderedTypes []string
@@ -187,18 +186,17 @@ func interactionScenarios() []interactionScenario {
},
},
{
name: "model-selected clarification is answered before investigation",
promise: "a model-selected first-turn question reaches the user and its answer returns through the same session before investigation continues",
prompt: "help me choose what to check",
answerQuestion: true,
name: "an unanswerable question call is refused without stalling the turn",
promise: "no Pulse client can answer pulse_question, so a model that calls it gets a refusal and keeps investigating instead of waiting forever; the user never sees a question card",
prompt: "help me choose what to check",
calls: []scriptedProviderCall{
providerQuestionToolCall("call_q1"),
providerQueryToolCall("call_r1"),
providerContentDone("No active alerts right now — everything looks healthy."),
},
maxTurns: 6,
orderedTypes: []string{"session", "question", "tool_start", "tool_end", "content", "done"},
forbiddenTypes: []string{"error"},
orderedTypes: []string{"session", "tool_start", "tool_end", "content", "done"},
forbiddenTypes: []string{"error", "question"},
answerMustContain: []string{"No active alerts"},
},
{
@@ -257,15 +255,6 @@ func TestInteractionScenarioCorpus(t *testing.T) {
defer cancel()
execErr := service.ExecuteStream(ctx, req, func(event StreamEvent) {
eventLog = append(eventLog, event.Type+"|"+string(event.Data))
if event.Type == "question" && sc.answerQuestion {
var question QuestionData
if err := json.Unmarshal(event.Data, &question); err != nil {
t.Fatal(err)
}
if err := service.AnswerQuestion(ctx, question.QuestionID, []QuestionAnswer{{ID: "q1", Value: "fleet"}}); err != nil {
t.Fatal(err)
}
}
if event.Type == "content" {
var data ContentData
if err := json.Unmarshal(event.Data, &data); err == nil {
+11 -192
View File
@@ -3,7 +3,6 @@ package chat
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
@@ -133,9 +132,8 @@ type Service struct {
// dashboard. Nil means "don't record".
costStore *cost.Store
activeMu sync.RWMutex
activeExecutions map[string]map[*AgenticLoop]struct{}
questionExecutions map[string]*AgenticLoop
activeMu sync.RWMutex
activeExecutions map[string]map[*AgenticLoop]struct{}
}
// SetPatrolProviderFactory installs a Patrol-only provider factory override.
@@ -192,7 +190,6 @@ func NewService(cfg Config) *Service {
controlLevelResolver: cfg.ControlLevelResolver,
costStore: cfg.CostStore,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
}
}
@@ -258,63 +255,6 @@ func (s *Service) unregisterActiveLoop(sessionID string, loop *AgenticLoop) {
delete(s.activeExecutions, sessionID)
}
}
for questionID, registeredLoop := range s.questionExecutions {
if registeredLoop == loop {
delete(s.questionExecutions, questionID)
}
}
}
// SteerSession routes a mid-turn steering message to the session's running
// agentic loop for injection at the next turn boundary. accepted=false with
// a reason is a normal outcome: the caller falls back to the ordinary
// follow-up queue. Steering carries prompt text only — it cannot change the
// model route, control level, or autonomous mode of the running turn.
func (s *Service) SteerSession(ctx context.Context, sessionID string, req SessionSteerRequest) (*SessionSteerResult, error) {
sessionID = strings.TrimSpace(sessionID)
if err := validateSessionID(sessionID); err != nil {
return nil, err
}
result := &SessionSteerResult{SessionID: sessionID}
if IsSystemSessionID(sessionID) {
result.Reason = "system_session"
return result, nil
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
result.Reason = "empty_prompt"
return result, nil
}
s.activeMu.RLock()
var loops []*AgenticLoop
for loop := range s.activeExecutions[sessionID] {
loops = append(loops, loop)
}
s.activeMu.RUnlock()
if len(loops) == 0 {
result.Reason = "no_active_run"
return result, nil
}
msg := Message{
ID: uuid.New().String(),
Role: "user",
Content: prompt,
Steered: true,
Timestamp: time.Now(),
}
for _, loop := range loops {
if err := loop.Steer(sessionID, msg, req.ClientMessageID); err != nil {
if errors.Is(err, errSteerBacklogFull) {
result.Reason = "steer_backlog"
return result, nil
}
return nil, err
}
}
result.Accepted = true
return result, nil
}
func assistantContextScopeForChatTurn(
@@ -394,30 +334,6 @@ func (s *Service) recordChatTurnCost(loop *AgenticLoop, requestModel, contextSco
})
}
func (s *Service) registerQuestionLoop(questionID string, loop *AgenticLoop) {
if s == nil || questionID == "" || loop == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.questionExecutions == nil {
s.questionExecutions = make(map[string]*AgenticLoop)
}
s.questionExecutions[questionID] = loop
}
func (s *Service) findQuestionLoop(questionID string) *AgenticLoop {
if s == nil || questionID == "" {
return nil
}
s.activeMu.RLock()
defer s.activeMu.RUnlock()
return s.questionExecutions[questionID]
}
func (s *Service) getActiveLoops(sessionID string) []*AgenticLoop {
if s == nil || sessionID == "" {
return nil
@@ -969,18 +885,8 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
withWorkflowModelRoute(attempt.Model),
)
attemptCallback := func(event StreamEvent) {
if event.Type == "question" {
var data QuestionData
if err := json.Unmarshal(event.Data, &data); err == nil && data.QuestionID != "" {
s.registerQuestionLoop(data.QuestionID, loop)
}
}
wrappedCallback(event)
}
s.registerActiveLoop(session.ID, loop)
resultMessages, err := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, attemptCallback)
resultMessages, err := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, wrappedCallback)
s.unregisterActiveLoop(session.ID, loop)
resultMessages = sanitizeMessagesForHandoffResourcePolicy(resultMessages, handoffResources, handoffResourceProvider)
s.recordChatTurnCost(loop, attempt.Model, contextScope, session.ID)
@@ -1017,9 +923,8 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
// Save result messages
for _, msg := range resultMessages {
// Skip user messages (already saved) — except steered ones, which
// entered the conversation inside the loop and exist nowhere else.
if msg.Role == "user" && msg.ToolResult == nil && !msg.Steered {
// Skip user messages (already saved before the loop).
if msg.Role == "user" && msg.ToolResult == nil {
continue
}
if msg.Role == "assistant" && strings.TrimSpace(msg.Model) == "" {
@@ -3100,27 +3005,6 @@ func (s *Service) ListAvailableTools(ctx context.Context, prompt string) []strin
return names
}
// AssistantSurfaceToolContract returns the live native Assistant surface tool
// summary for the current runtime mode. It keeps API/UI consumers on the
// executor-owned projection instead of rebuilding tool buckets from names.
func (s *Service) AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract {
s.mu.RLock()
executor := s.executor
effectiveControlLevel := s.effectiveControlLevelLocked()
s.mu.RUnlock()
if executor == nil {
return agentcapabilities.SurfaceToolContract{}
}
executor = executor.Clone()
executor.SetControlLevel(effectiveControlLevel)
executor.ApplyExecutionProfile(tools.ProfileInteractiveAssistant)
executor.SetExecuteAuthority(executeAuthorityFromContext(ctx))
return executor.AssistantSurfaceToolContract(agentcapabilities.AssistantProviderToolOptions{
IncludeQuestionTool: !s.isAutonomousModeEnabled(),
})
}
// ListSessions returns all sessions
// maxSessionHandoffRefreshPerList bounds how many sessions get their handoff
// action status re-checked per list call. The list is newest-first, so this
@@ -3384,67 +3268,6 @@ func (s *Service) AbortSession(ctx context.Context, sessionID string) error {
return nil
}
// AnswerQuestion provides answers to a pending question
func (s *Service) AnswerQuestion(ctx context.Context, questionID string, answers []QuestionAnswer) error {
s.mu.RLock()
started := s.started
agenticLoop := s.agenticLoop
s.mu.RUnlock()
if !started {
return fmt.Errorf("service not started")
}
if loop := s.findQuestionLoop(questionID); loop != nil {
return loop.AnswerQuestion(questionID, answers)
}
if agenticLoop == nil {
return fmt.Errorf("service not started")
}
return agenticLoop.AnswerQuestion(questionID, answers)
}
// ForkSession creates a branch
func (s *Service) ForkSession(ctx context.Context, sessionID string) (*Session, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.Fork(sessionID)
}
// UndoLastTurn removes the latest user turn from the durable chat session.
func (s *Service) UndoLastTurn(ctx context.Context, sessionID string, opts SessionTurnUndoOptions) (*SessionTurnUndoResult, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.UndoLastTurnWithOptions(sessionID, opts)
}
// RedoLastTurn restores the latest turn removed by UndoLastTurn.
func (s *Service) RedoLastTurn(ctx context.Context, sessionID string) (*SessionTurnRedoResult, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.RedoLastTurn(sessionID)
}
// GetBaseURL returns empty since there's no sidecar
func (s *Service) GetBaseURL() string {
return ""
@@ -3805,9 +3628,9 @@ func (s *Service) buildSystemPromptWithToolGovernance(toolGovernance string) str
- Write tools are allowed only when the user explicitly asks you to perform an action.
- Status checks and monitoring are read-oriented; do not change state unless the user asked for a state change.
- Resolve a missing target yourself before asking: use read-only query/topology tools to see which hosts, VMs, or containers exist. If exactly one plausible target matches the request (a single connected host, or one resource by that name), run read-only diagnostics against it and name the target in your answer instead of asking.
- Ask for the target only when several plausible targets remain after looking, or when the action changes state. If a structured clarification tool is offered, use it for missing critical information (target, risky choice, preference); otherwise ask in normal assistant text.
- Ask for the target only when several plausible targets remain after looking, or when the action changes state. Ask for missing critical information (target, risky choice, preference) in normal assistant text.
- The same rule covers tool arguments: when a tool call fails because an argument is missing or invalid, recover yourself — enumerate the missing values with read-only tools (or retry the tool with the arguments it says it accepts) instead of asking the user. Never ask the user for internal identifiers such as resource IDs, UUIDs, or metric keys; users do not know them, and questions are reserved for preferences, risky choices, and genuine ambiguity between named options.
- Asking may not be your first action: a clarification request issued before you have attempted any tool call this turn will be rejected. For broad questions ("how is my infrastructure doing?", "any alerts?"), call the obvious read-only tool with defaults — pulse_summarize {"action":"fleet"} needs no parameters, and the alert tools list everything without a target.
- Look before you ask: for broad questions ("how is my infrastructure doing?", "any alerts?"), call the obvious read-only tool with defaults — pulse_summarize {"action":"fleet"} needs no parameters, and the alert tools list everything without a target.
- Never guess a target you did not resolve. Do not attempt a tool call with current_resource or another placeholder as a stand-in for a missing target — in autonomous mode the same rules apply: resolve with read-only tools first, then ask in normal assistant text if genuine ambiguity remains.
## USER JOB
@@ -4022,19 +3845,15 @@ func firstNonEmptyRecentString(values ...string) string {
// toolsForExecutor projects the provider tool manifest from ONE effective
// request executor: the same clone (control level, autonomy, execution
// profile already applied) that will execute the calls at runtime, so the
// offered schema and the enforcement boundary can never disagree. The
// profile owns question-tool availability: non-interactive Patrol
// profiles never offer it, and the interactive profile keeps the
// historical autonomous exemption.
// offered schema and the enforcement boundary can never disagree. No
// profile offers pulse_question: no Pulse client can answer it since the
// web chat was retired, and the agentic loop refuses fabricated calls.
func (s *Service) toolsForExecutor(executor *tools.PulseToolExecutor, autonomousMode bool) []providers.Tool {
if executor == nil {
executor = s.executor
}
profile := executor.ExecutionProfile()
includeQuestionTool := !profile.NonInteractive() && !autonomousMode
providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{
IncludeQuestionTool: includeQuestionTool,
})
providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{})
// Patrol detection may be scoped by explicit product configuration,
// but never by prompt keyword inference. The selected Patrol model
+9 -94
View File
@@ -331,59 +331,9 @@ func TestService_SessionWrappers(t *testing.T) {
require.Len(t, list, 1)
assert.Equal(t, "Renamed session", list[0].Title)
// Fork Session
forked, err := service.ForkSession(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, forked)
assert.NotEqual(t, session.ID, forked.ID)
assert.Equal(t, 1, forked.MessageCount)
assert.Equal(t, "Fork of Renamed session", forked.Title)
forkedMessages, err := service.GetMessages(ctx, forked.ID)
require.NoError(t, err)
require.Len(t, forkedMessages, 1)
assert.Equal(t, "I will inspect the device nodes.", forkedMessages[0].Content)
assert.NotContains(t, forkedMessages[0].Content, "pulse_read")
require.NoError(t, service.sessions.AddMessage(forked.ID, Message{
Role: "user",
Content: "How many device nodes?",
}))
require.NoError(t, service.sessions.AddMessage(forked.ID, Message{
Role: "assistant",
Content: "There are 42 device nodes.",
}))
undo, err := service.UndoLastTurn(ctx, forked.ID, SessionTurnUndoOptions{})
require.NoError(t, err)
require.NotNil(t, undo)
assert.True(t, undo.Success)
assert.Equal(t, "How many device nodes?", undo.RestoredPrompt)
assert.True(t, undo.CanRedo)
forkedMessages, err = service.GetMessages(ctx, forked.ID)
require.NoError(t, err)
require.Len(t, forkedMessages, 1)
assert.Equal(t, "I will inspect the device nodes.", forkedMessages[0].Content)
redo, err := service.RedoLastTurn(ctx, forked.ID)
require.NoError(t, err)
require.NotNil(t, redo)
assert.True(t, redo.Success)
assert.False(t, redo.CanRedo)
forkedMessages, err = service.GetMessages(ctx, forked.ID)
require.NoError(t, err)
require.Len(t, forkedMessages, 3)
list, err = service.ListSessions(ctx)
require.NoError(t, err)
assert.Len(t, list, 2)
// Delete Session
err = service.DeleteSession(ctx, session.ID)
require.NoError(t, err)
err = service.DeleteSession(ctx, forked.ID)
require.NoError(t, err)
// Verify deletion via List
list, err = service.ListSessions(ctx)
@@ -530,58 +480,22 @@ func TestService_ExtendedMethods(t *testing.T) {
service := NewService(cfg)
ctx := context.Background()
// 1. AnswerQuestion - fails if service not started
err := service.AnswerQuestion(ctx, "q1", nil)
assert.Error(t, err)
// 2. Session compaction is a real service operation and requires startup state.
res, err := service.SummarizeSession(ctx, "s1")
assert.Error(t, err)
assert.Nil(t, res)
// 3. ForkSession returns error
_, err = service.ForkSession(ctx, "s1")
assert.Error(t, err)
// 4. GetBaseURL
// 1. GetBaseURL
url := service.GetBaseURL()
assert.Equal(t, "", url)
// 5. AbortSession
err = service.AbortSession(ctx, "s1")
// 2. AbortSession
err := service.AbortSession(ctx, "s1")
assert.Error(t, err) // Service not started
}
func TestService_AnswerQuestion_UsesRegisteredActiveLoop(t *testing.T) {
service := &Service{
started: true,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
}
answersCh := make(chan []QuestionAnswer, 1)
loop := &AgenticLoop{
pendingQs: map[string]chan []QuestionAnswer{
"q-live": answersCh,
},
aborted: make(map[string]bool),
}
service.registerQuestionLoop("q-live", loop)
answers := []QuestionAnswer{{ID: "target", Value: "node-a"}}
err := service.AnswerQuestion(context.Background(), "q-live", answers)
require.NoError(t, err)
assert.Equal(t, answers, <-answersCh)
}
func TestService_AbortSession_UsesRegisteredActiveLoops(t *testing.T) {
service := &Service{
started: true,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
started: true,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
}
loop := &AgenticLoop{
aborted: make(map[string]bool),
pendingQs: make(map[string]chan []QuestionAnswer),
aborted: make(map[string]bool),
}
service.registerActiveLoop("session-live", loop)
@@ -643,7 +557,7 @@ func TestService_ToolsForExecutionMode_ExposesGovernedTools(t *testing.T) {
assert.True(t, hasProviderTool(filtered, "pulse_query"))
}
func TestService_ToolsForExecutionMode_InteractiveAddsQuestionTool(t *testing.T) {
func TestService_ToolsForExecutionMode_InteractiveOmitsQuestionTool(t *testing.T) {
service := NewService(Config{
AIConfig: &config.AIConfig{ControlLevel: config.ControlLevelControlled},
StateProvider: &mockStateProvider{},
@@ -658,7 +572,8 @@ func TestService_ToolsForExecutionMode_InteractiveAddsQuestionTool(t *testing.T)
assert.True(t, hasProviderTool(filtered, "pulse_control"))
assert.True(t, hasProviderTool(filtered, "pulse_docker"))
assert.True(t, hasProviderTool(filtered, "pulse_query"))
assert.True(t, hasProviderTool(filtered, pulseQuestionToolName))
// No Pulse client can answer pulse_question since the web chat was retired.
assert.False(t, hasProviderTool(filtered, pulseQuestionToolName))
}
func TestService_Execute_NonStreaming(t *testing.T) {
+13 -77
View File
@@ -97,15 +97,6 @@ func toolNameSet(list []providers.Tool) map[string]bool {
return set
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestToolsForExecutionMode_InteractiveExposesGovernedTools(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
@@ -130,8 +121,8 @@ func TestToolsForExecutionMode_InteractiveExposesGovernedTools(t *testing.T) {
!interactiveSet["pulse_docker"] {
t.Fatalf("expected interactive chat to expose all governed tools to the selected model")
}
if !interactiveSet[pulseQuestionToolName] {
t.Fatalf("expected interactive chat to expose the clarification tool")
if interactiveSet[pulseQuestionToolName] {
t.Fatalf("interactive chat must not offer pulse_question: no Pulse client can answer it")
}
}
@@ -232,65 +223,6 @@ func TestAssistantPromptQualifiesForLocalInventoryCount(t *testing.T) {
}
}
func TestAssistantSurfaceToolContractUsesRuntimeAssistantProjection(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
TypedActionPlanner: &gateTestPlanner{},
AgentServer: fakeAgentServer{},
ReadState: &fakeCanonicalReadState{},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{
executor: exec,
cfg: &config.AIConfig{ControlLevel: config.ControlLevelControlled},
}
interactive := svc.AssistantSurfaceToolContract(context.Background())
if interactive.SurfaceID != agentcapabilities.SurfaceIDPulseAssistant {
t.Fatalf("surface id = %q", interactive.SurfaceID)
}
if interactive.ToolSource != agentcapabilities.SurfaceToolSourceAssistantRegistry {
t.Fatalf("tool source = %q", interactive.ToolSource)
}
if !stringSliceContains(interactive.ToolNames, agentcapabilities.PulseQueryToolName) {
t.Fatalf("interactive surface missing registry query tool: %#v", interactive.ToolNames)
}
if !stringSliceContains(interactive.RegistryToolNames, agentcapabilities.PulseQueryToolName) {
t.Fatalf("interactive surface missing registry bucket: %#v", interactive.RegistryToolNames)
}
if !stringSliceContains(interactive.NativeToolNames, agentcapabilities.PulseQuestionToolName) {
t.Fatalf("interactive surface missing native question bucket: %#v", interactive.NativeToolNames)
}
if len(interactive.CapabilityNames) != 0 {
t.Fatalf("Assistant surface must not expose MCP capability names: %#v", interactive.CapabilityNames)
}
if stringSliceContains(interactive.ToolNames, agentcapabilities.PulseControlToolName) {
t.Fatalf("chat-only surface exposed infrastructure control: %#v", interactive.ToolNames)
}
executeAuthorized := svc.AssistantSurfaceToolContract(WithExecuteAuthority(context.Background(), true))
if !stringSliceContains(executeAuthorized.ToolNames, agentcapabilities.PulseControlToolName) {
t.Fatalf("execute-authorized controlled surface missing governed infrastructure control: %#v", executeAuthorized.ToolNames)
}
// Execute authority cannot substitute for an installed planner.
svc.SetTypedActionPlanner(nil)
unavailable := svc.AssistantSurfaceToolContract(WithExecuteAuthority(context.Background(), true))
if stringSliceContains(unavailable.ToolNames, agentcapabilities.PulseControlToolName) {
t.Fatalf("planner-less surface exposed control: %#v", unavailable.ToolNames)
}
svc.SetAutonomousMode(true)
autonomous := svc.AssistantSurfaceToolContract(context.Background())
if stringSliceContains(autonomous.NativeToolNames, agentcapabilities.PulseQuestionToolName) ||
stringSliceContains(autonomous.ToolNames, agentcapabilities.PulseQuestionToolName) {
t.Fatalf("autonomous Assistant surface must not expose interactive question tool: %+v", autonomous)
}
if !stringSliceContains(autonomous.RegistryToolNames, agentcapabilities.PulseQueryToolName) {
t.Fatalf("autonomous surface should retain registry tools: %#v", autonomous.RegistryToolNames)
}
}
func TestToolsForExecutionMode_AutonomousNonPatrolExposesGovernedTools(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
@@ -462,16 +394,20 @@ func TestBuildToolGovernancePromptSection_FallbackOfferedNamesUseAssistantAfford
fallback := fallbackAssistantToolGovernance()
names := fallbackAssistantToolGovernanceOfferedNames(fallback)
if len(names) != len(fallback)+1 {
t.Fatalf("fallback offered names length = %d, want governance names plus question tool", len(names))
// The canonical Assistant surface declares no interactive questions, so
// the fallback names are exactly the governance names.
if len(names) != len(fallback) {
t.Fatalf("fallback offered names length = %d, want governance names only", len(names))
}
for i, tool := range fallback {
if names[i] != tool.Name {
t.Fatalf("fallback offered names[%d] = %q, want %q", i, names[i], tool.Name)
}
}
if names[len(names)-1] != agentcapabilities.PulseQuestionToolName {
t.Fatalf("fallback offered names must include manifest-enabled question tool last, got %#v", names)
for _, name := range names {
if name == agentcapabilities.PulseQuestionToolName {
t.Fatalf("fallback offered names must not include pulse_question, got %#v", names)
}
}
}
@@ -1007,7 +943,7 @@ func TestExecutionProfilePromptModes(t *testing.T) {
}
}
func TestToolsForExecutorHidesQuestionToolForPatrolProfiles(t *testing.T) {
func TestToolsForExecutorNeverOffersQuestionTool(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
ControlLevel: tools.ControlLevelControlled,
StateProvider: &mockStateProvider{},
@@ -1016,8 +952,8 @@ func TestToolsForExecutorHidesQuestionToolForPatrolProfiles(t *testing.T) {
svc := &Service{executor: exec, cfg: &config.AIConfig{}}
interactive := svc.toolsForExecutor(nil, false)
if !toolNameSet(interactive)[pulseQuestionToolName] {
t.Fatal("interactive non-autonomous manifest must offer pulse_question")
if toolNameSet(interactive)[pulseQuestionToolName] {
t.Fatal("interactive manifest must not offer pulse_question: no Pulse client can answer it")
}
detection := exec.Clone()
+14 -234
View File
@@ -73,22 +73,14 @@ type sessionSummaryIndexData struct {
Entries map[string]sessionSummaryIndexEntry `json:"entries"`
}
const maxSessionTurnRedoStack = 10
// sessionData is the on-disk format for a session
type sessionData struct {
ID string `json:"id"`
Title string `json:"title"`
Messages []Message `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
TurnRedoStack []sessionTurnRedoEntry `json:"turn_redo_stack,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionTurnRedoEntry struct {
Messages []Message `json:"messages"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Title string `json:"title"`
Messages []Message `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionModelContext struct {
@@ -100,17 +92,6 @@ type sessionModelContext struct {
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func cloneSessionMessages(messages []Message) []Message {
if len(messages) == 0 {
return nil
}
cloned := make([]Message, len(messages))
for i, msg := range messages {
cloned[i] = msg.NormalizeCollections()
}
return cloned
}
func normalizeHandoffResources(resources []HandoffResource) []HandoffResource {
if len(resources) == 0 {
return nil
@@ -296,20 +277,6 @@ func modelContextEmpty(modelContext *sessionModelContext) bool {
handoffMetadataEmpty(modelContext.HandoffMetadata)
}
func cloneSessionModelContext(modelContext *sessionModelContext) *sessionModelContext {
if modelContext == nil {
return nil
}
clone := *modelContext
if len(modelContext.HandoffResources) > 0 {
clone.HandoffResources = append([]HandoffResource(nil), modelContext.HandoffResources...)
}
if len(modelContext.HandoffActions) > 0 {
clone.HandoffActions = append([]HandoffAction(nil), modelContext.HandoffActions...)
}
return &clone
}
const (
sessionHandoffKindPatrolAssessment = "patrol_assessment"
sessionHandoffKindPatrolConfigurationFailure = "patrol_configuration_failure"
@@ -418,23 +385,21 @@ func sessionSummaryFromData(data sessionData) Session {
CreatedAt: data.CreatedAt,
UpdatedAt: data.UpdatedAt,
MessageCount: len(data.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
System: IsSystemSessionID(data.ID),
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
}
}
// sessionListData decodes only what a session summary needs. Message and redo
// sessionListData decodes only what a session summary needs. Message
// payloads stay as raw JSON so listing cost does not scale with transcript
// size — only their counts matter here.
// size — only their count matters here.
type sessionListData struct {
ID string `json:"id"`
Title string `json:"title"`
Messages []json.RawMessage `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
TurnRedoStack []json.RawMessage `json:"turn_redo_stack,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Title string `json:"title"`
Messages []json.RawMessage `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sessionSummaryFromListData(data sessionListData) Session {
@@ -444,7 +409,6 @@ func sessionSummaryFromListData(data sessionListData) Session {
CreatedAt: data.CreatedAt,
UpdatedAt: data.UpdatedAt,
MessageCount: len(data.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
System: IsSystemSessionID(data.ID),
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
}
@@ -759,179 +723,6 @@ func (s *SessionStore) Rename(id, title string) (*Session, error) {
return &session, nil
}
// Fork clones a persisted session into a new durable session. The copied
// messages intentionally preserve their per-session IDs so tool-call/result
// relationships remain intact inside the forked transcript.
func (s *SessionStore) Fork(id string) (*Session, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
source, err := s.readSession(id)
if err != nil {
return nil, err
}
now := time.Now()
messages := cloneSessionMessages(source.Messages)
title := strings.TrimSpace(source.Title)
if title == "" {
for _, msg := range messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
title = generateTitle(msg.Content)
break
}
}
}
if title == "" {
title = "Forked session"
} else if !strings.HasPrefix(strings.ToLower(title), "fork of ") {
title = "Fork of " + title
}
fork := sessionData{
ID: uuid.New().String(),
Title: title,
Messages: messages,
ModelContext: cloneSessionModelContext(source.ModelContext),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.writeSession(fork); err != nil {
return nil, err
}
session := sessionSummaryFromData(fork)
return &session, nil
}
// UndoLastTurn removes the latest user-authored turn from a session and stores
// the removed messages so RedoLastTurn can restore them.
func (s *SessionStore) UndoLastTurn(id string) (*SessionTurnUndoResult, error) {
return s.UndoLastTurnWithOptions(id, SessionTurnUndoOptions{})
}
// UndoLastTurnWithOptions removes the latest user-authored turn subject to the
// given options: an expected-prompt guard so retry/regenerate flows cannot
// remove a turn other than the one being re-run.
func (s *SessionStore) UndoLastTurnWithOptions(id string, opts SessionTurnUndoOptions) (*SessionTurnUndoResult, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
lastUserIndex := -1
for i := len(data.Messages) - 1; i >= 0; i-- {
if strings.EqualFold(data.Messages[i].Role, "user") {
lastUserIndex = i
break
}
}
if lastUserIndex < 0 {
return &SessionTurnUndoResult{
Success: false,
SessionID: data.ID,
CanRedo: len(data.TurnRedoStack) > 0,
Message: "No user turn to undo.",
}, nil
}
if expected := strings.TrimSpace(opts.ExpectedPrompt); expected != "" &&
strings.TrimSpace(data.Messages[lastUserIndex].Content) != expected {
return &SessionTurnUndoResult{
Success: false,
SessionID: data.ID,
CanRedo: len(data.TurnRedoStack) > 0,
Message: "The latest turn no longer matches the prompt being retried.",
}, nil
}
removed := cloneSessionMessages(data.Messages[lastUserIndex:])
prompt := ""
for _, msg := range removed {
if strings.EqualFold(msg.Role, "user") {
prompt = msg.Content
break
}
}
now := time.Now()
data.Messages = cloneSessionMessages(data.Messages[:lastUserIndex])
data.TurnRedoStack = append(data.TurnRedoStack, sessionTurnRedoEntry{
Messages: removed,
UpdatedAt: now,
})
if len(data.TurnRedoStack) > maxSessionTurnRedoStack {
data.TurnRedoStack = data.TurnRedoStack[len(data.TurnRedoStack)-maxSessionTurnRedoStack:]
}
data.UpdatedAt = now
if err := s.writeSession(*data); err != nil {
return nil, err
}
return &SessionTurnUndoResult{
Success: true,
SessionID: data.ID,
RestoredPrompt: prompt,
RemovedMessages: len(removed),
CanRedo: len(data.TurnRedoStack) > 0,
}, nil
}
// RedoLastTurn restores the most recently undone turn, if one is available.
func (s *SessionStore) RedoLastTurn(id string) (*SessionTurnRedoResult, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
if len(data.TurnRedoStack) == 0 {
return &SessionTurnRedoResult{
Success: false,
SessionID: data.ID,
CanRedo: false,
Message: "No undone turn to redo.",
}, nil
}
entryIndex := len(data.TurnRedoStack) - 1
entry := data.TurnRedoStack[entryIndex]
data.TurnRedoStack = data.TurnRedoStack[:entryIndex]
data.Messages = append(data.Messages, cloneSessionMessages(entry.Messages)...)
if len(data.TurnRedoStack) == 0 {
data.TurnRedoStack = nil
}
data.UpdatedAt = time.Now()
if err := s.writeSession(*data); err != nil {
return nil, err
}
return &SessionTurnRedoResult{
Success: true,
SessionID: data.ID,
RestoredMessages: len(entry.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
}, nil
}
// Delete deletes a session
func (s *SessionStore) Delete(id string) error {
s.mu.Lock()
@@ -1017,7 +808,6 @@ func (s *SessionStore) TrimMessages(id string, keepMostRecent int) error {
trimmed := make([]Message, keepMostRecent)
copy(trimmed, data.Messages[start:])
data.Messages = trimmed
data.TurnRedoStack = nil
data.UpdatedAt = time.Now()
return s.writeSession(*data)
}
@@ -1042,7 +832,6 @@ func (s *SessionStore) AddMessage(id string, msg Message) error {
msg = msg.NormalizeCollections()
data.Messages = append(data.Messages, msg)
data.TurnRedoStack = nil
data.UpdatedAt = time.Now()
// Auto-generate title from first user message if not set
@@ -1422,9 +1211,6 @@ func (s *SessionStore) readSessionWithLegacyScan(id string, allowLegacyScan bool
for i := range data.Messages {
data.Messages[i] = data.Messages[i].NormalizeCollections()
}
for i := range data.TurnRedoStack {
data.TurnRedoStack[i].Messages = cloneSessionMessages(data.TurnRedoStack[i].Messages)
}
return &data, nil
}
@@ -1437,12 +1223,6 @@ func (s *SessionStore) writeSession(data sessionData) error {
for i := range data.Messages {
data.Messages[i] = data.Messages[i].NormalizeCollections()
}
for i := range data.TurnRedoStack {
data.TurnRedoStack[i].Messages = cloneSessionMessages(data.TurnRedoStack[i].Messages)
}
if len(data.TurnRedoStack) == 0 {
data.TurnRedoStack = nil
}
file, err := json.MarshalIndent(data, "", " ")
if err != nil {
-447
View File
@@ -1,447 +0,0 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
)
const (
sessionCompactionKeepRecentUserTurns = 4
sessionCompactionMinimumMessages = 4
sessionCompactionTranscriptMaxChars = 52000
sessionCompactionEntryMaxChars = 2400
sessionCompactionToolOutputMaxChars = 1200
sessionCompactionSummaryMaxChars = 9000
sessionCompactionTimeout = 45 * time.Second
)
const sessionCompactionSystemPrompt = `You compact Pulse Assistant chat sessions for future turns.
Produce a concise handoff summary that preserves:
- The user's goal and current task.
- Named infrastructure resources, hosts, VMs, containers, services, alerts, and findings.
- Facts already established from tools or prior assistant answers.
- Actions taken, approvals requested, safety boundaries, and failed attempts.
- Open questions and caveats the next turn must remember.
Do not invent facts. Do not include hidden reasoning. Do not include raw secrets, tokens, private keys, or passwords. Keep it practical and suitable as context for the next Pulse Assistant turn.`
// SessionCompactionResult describes a durable session rewrite after compacting
// older turns into a model-produced handoff summary.
type SessionCompactionResult struct {
Success bool `json:"success"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
SessionID string `json:"session_id"`
SummaryMessageID string `json:"summary_message_id,omitempty"`
OriginalMessageCount int `json:"original_message_count"`
CompactedMessageCount int `json:"compacted_message_count"`
CompactedMessages int `json:"compacted_messages"`
KeptRecentMessages int `json:"kept_recent_messages"`
SummaryChars int `json:"summary_chars"`
}
func (r SessionCompactionResult) asMap() map[string]interface{} {
return map[string]interface{}{
"success": r.Success,
"status": r.Status,
"message": r.Message,
"session_id": r.SessionID,
"summary_message_id": r.SummaryMessageID,
"original_message_count": r.OriginalMessageCount,
"compacted_message_count": r.CompactedMessageCount,
"compacted_messages": r.CompactedMessages,
"kept_recent_messages": r.KeptRecentMessages,
"summary_chars": r.SummaryChars,
}
}
// SummarizeSession compacts a durable chat session using the active chat model.
func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
normalizedSessionID := strings.TrimSpace(sessionID)
if normalizedSessionID == "" {
return nil, fmt.Errorf("session id required")
}
s.mu.RLock()
started := s.started
sessions := s.sessions
provider := s.provider
cfgSnapshot := s.cfg
costStore := s.costStore
unifiedResourceProvider := s.unifiedResourceProvider
s.mu.RUnlock()
if !started {
return nil, fmt.Errorf("service not started")
}
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
if provider == nil {
return nil, fmt.Errorf("provider not available")
}
if len(s.getActiveLoops(normalizedSessionID)) > 0 {
return nil, fmt.Errorf("session is currently running; wait for the active response to finish or stop it before compacting")
}
messages, err := sessions.GetMessages(normalizedSessionID)
if err != nil {
return nil, err
}
if len(messages) < sessionCompactionMinimumMessages {
return SessionCompactionResult{
Success: false,
Status: "not_needed",
Message: "This Assistant session is already short.",
SessionID: normalizedSessionID,
OriginalMessageCount: len(messages),
CompactedMessageCount: len(messages),
}.asMap(), nil
}
transcript := buildSessionCompactionTranscript(messages)
if strings.TrimSpace(transcript) == "" {
return SessionCompactionResult{
Success: false,
Status: "empty",
Message: "There is no Assistant transcript to compact.",
SessionID: normalizedSessionID,
OriginalMessageCount: len(messages),
CompactedMessageCount: len(messages),
}.asMap(), nil
}
requestModel := ""
if cfgSnapshot != nil {
requestModel = strings.TrimSpace(cfgSnapshot.GetChatModel())
}
compactCtx, cancel := context.WithTimeout(ctx, sessionCompactionTimeout)
defer cancel()
compactionRequest := providers.ChatRequest{
System: sessionCompactionSystemPrompt,
ExecutionID: "session-compaction:" + normalizedSessionID,
MaxTokens: 1600,
Temperature: 0.2,
Messages: []providers.Message{{
Role: "user",
Content: "Compact this Pulse Assistant transcript into a handoff summary for the next turn.\n\n" +
transcript,
}},
}
// The transcript is built from PERSISTED messages (original user prompts and
// tool outputs), which carry raw resource identifiers. Run it through the same
// model-boundary sanitizer as a normal turn so compaction never leaks
// local-only identifiers or credentials to a cloud model (local Ollama is
// unaffected — RequestSanitizerForModel returns nil).
if sanitizer := modelboundary.RequestSanitizerForModel(requestModel, unifiedResourceProvider); sanitizer != nil {
compactionRequest = sanitizer(compactionRequest)
}
response, err := provider.Chat(compactCtx, compactionRequest)
if err != nil {
return nil, fmt.Errorf("compact session: %w", err)
}
if costStore != nil && response != nil && (response.InputTokens != 0 || response.OutputTokens != 0) {
providerName := ""
if provider != nil {
providerName = provider.Name()
}
costStore.Record(cost.UsageEvent{
Timestamp: time.Now(),
Provider: providerName,
RequestModel: requestModel,
ResponseModel: strings.TrimSpace(response.Model),
UseCase: "chat",
InputTokens: response.InputTokens,
OutputTokens: response.OutputTokens,
TargetType: "assistant_session_compaction",
TargetID: normalizedSessionID,
SessionID: normalizedSessionID,
})
}
summary := normalizeSessionCompactionSummary(response.Content)
if summary == "" {
return nil, fmt.Errorf("compact session: model returned an empty summary")
}
responseModel := strings.TrimSpace(response.Model)
if responseModel == "" {
responseModel = requestModel
}
result, err := sessions.CompactWithSummary(
normalizedSessionID,
summary,
sessionCompactionKeepRecentUserTurns,
responseModel,
)
if err != nil {
return nil, err
}
return result.asMap(), nil
}
// CompactWithSummary rewrites a session to a model-produced summary plus the
// most recent turns. The full transcript is intentionally not kept in the hot
// model path after compaction.
func (s *SessionStore) CompactWithSummary(
id string,
summary string,
keepRecentUserTurns int,
model string,
) (*SessionCompactionResult, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
summary = normalizeSessionCompactionSummary(summary)
if summary == "" {
return nil, fmt.Errorf("summary required")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
originalCount := len(data.Messages)
recentMessages := selectRecentMessagesForSessionCompaction(data.Messages, keepRecentUserTurns)
now := time.Now()
summaryMessage := Message{
ID: uuid.New().String(),
Role: "assistant",
Content: buildCompactedSessionMessage(summary, originalCount, len(recentMessages)),
Model: strings.TrimSpace(model),
Timestamp: now,
}
nextMessages := make([]Message, 0, 1+len(recentMessages))
nextMessages = append(nextMessages, summaryMessage)
nextMessages = append(nextMessages, recentMessages...)
data.Messages = nextMessages
data.TurnRedoStack = nil
data.UpdatedAt = now
if err := s.writeSession(*data); err != nil {
return nil, err
}
compactedMessages := originalCount - len(recentMessages)
if compactedMessages < 0 {
compactedMessages = 0
}
return &SessionCompactionResult{
Success: true,
Status: "compacted",
Message: fmt.Sprintf("Compacted %d older messages into a session summary.", compactedMessages),
SessionID: data.ID,
SummaryMessageID: summaryMessage.ID,
OriginalMessageCount: originalCount,
CompactedMessageCount: len(data.Messages),
CompactedMessages: compactedMessages,
KeptRecentMessages: len(recentMessages),
SummaryChars: len([]rune(summary)),
}, nil
}
func selectRecentMessagesForSessionCompaction(messages []Message, keepUserTurns int) []Message {
if keepUserTurns <= 0 || len(messages) == 0 {
return nil
}
start := len(messages)
userTurns := 0
for index := len(messages) - 1; index >= 0; index-- {
msg := messages[index]
if msg.ToolResult != nil {
continue
}
if !strings.EqualFold(msg.Role, "user") {
continue
}
if strings.TrimSpace(msg.Content) == "" {
continue
}
start = index
userTurns++
if userTurns >= keepUserTurns {
break
}
}
if start >= len(messages) {
return nil
}
recent := cloneSessionMessages(messages[start:])
for len(recent) > 0 && recent[0].ToolResult != nil {
recent = recent[1:]
}
return recent
}
func buildCompactedSessionMessage(summary string, originalMessageCount int, keptRecentMessages int) string {
return strings.TrimSpace(fmt.Sprintf(
"Session compacted. Older turns were summarized for context; the latest %d messages remain below.\n\n%s\n\nCompaction source: %d original messages.",
keptRecentMessages,
summary,
originalMessageCount,
))
}
func normalizeSessionCompactionSummary(summary string) string {
summary = strings.TrimSpace(summary)
summary, _ = safety.RedactSensitiveText(summary)
return truncateSessionCompactionText(summary, sessionCompactionSummaryMaxChars)
}
func buildSessionCompactionTranscript(messages []Message) string {
entries := make([]string, 0, len(messages))
for index, message := range messages {
if entry := formatSessionCompactionTranscriptEntry(index, message); entry != "" {
entries = append(entries, entry)
}
}
return fitSessionCompactionEntries(entries, sessionCompactionTranscriptMaxChars)
}
func formatSessionCompactionTranscriptEntry(index int, message Message) string {
var parts []string
role := strings.TrimSpace(message.Role)
if role == "" {
role = "message"
}
if message.ToolResult != nil {
status := "ok"
if message.ToolResult.IsError {
status = "error"
}
content := sanitizeSessionCompactionText(message.ToolResult.Content)
if content == "" {
return ""
}
return fmt.Sprintf(
"[%03d] TOOL RESULT %s (%s)\n%s",
index+1,
strings.TrimSpace(message.ToolResult.ToolUseID),
status,
truncateSessionCompactionText(content, sessionCompactionToolOutputMaxChars),
)
}
content := sanitizeSessionCompactionText(message.Content)
if content != "" {
parts = append(parts, truncateSessionCompactionText(content, sessionCompactionEntryMaxChars))
}
for _, toolCall := range message.ToolCalls {
parts = append(parts, formatSessionCompactionToolCall(toolCall))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("[%03d] %s\n%s", index+1, strings.ToUpper(role), strings.Join(parts, "\n"))
}
func formatSessionCompactionToolCall(toolCall ToolCall) string {
input := "{}"
if len(toolCall.Input) > 0 {
if data, err := json.Marshal(toolCall.Input); err == nil {
input = string(data)
input, _ = safety.RedactSensitiveText(input)
}
}
parts := []string{
fmt.Sprintf("Tool call: %s %s", strings.TrimSpace(toolCall.Name), input),
}
output := sanitizeSessionCompactionText(toolCall.Output)
if output != "" {
parts = append(parts, "Tool output: "+truncateSessionCompactionText(output, sessionCompactionToolOutputMaxChars))
}
return strings.Join(parts, "\n")
}
func sanitizeSessionCompactionText(value string) string {
value = cleanToolCallArtifacts(strings.TrimSpace(value))
value, _ = safety.RedactSensitiveText(value)
return strings.TrimSpace(value)
}
func fitSessionCompactionEntries(entries []string, maxChars int) string {
if len(entries) == 0 || maxChars <= 0 {
return ""
}
full := strings.Join(entries, "\n\n")
if len([]rune(full)) <= maxChars {
return full
}
headBudget := maxChars / 2
tailBudget := maxChars - headBudget
var head []string
var headChars int
headEnd := 0
for headEnd < len(entries) {
entryChars := len([]rune(entries[headEnd])) + 2
if headChars+entryChars > headBudget {
break
}
head = append(head, entries[headEnd])
headChars += entryChars
headEnd++
}
var tail []string
var tailChars int
tailStart := len(entries)
for tailStart > headEnd {
entry := entries[tailStart-1]
entryChars := len([]rune(entry)) + 2
if tailChars+entryChars > tailBudget {
break
}
tail = append([]string{entry}, tail...)
tailChars += entryChars
tailStart--
}
omitted := tailStart - headEnd
if omitted < 0 {
omitted = 0
}
notice := fmt.Sprintf("[Compaction transcript omitted %d middle entries to fit the request budget.]", omitted)
combined := append(head, notice)
combined = append(combined, tail...)
return strings.Join(combined, "\n\n")
}
func truncateSessionCompactionText(value string, maxRunes int) string {
value = strings.TrimSpace(value)
if maxRunes <= 0 {
return value
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return strings.TrimSpace(string(runes[:maxRunes])) + "\n[truncated]"
}
@@ -1,187 +0,0 @@
package chat
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestFormatSessionCompactionToolCall exercises every branch of
// formatSessionCompactionToolCall: empty/nil input maps, the JSON marshal
// success and failure paths, secret redaction on both Input and Output, name
// trimming, output sanitization (including whitespace-only and emoji-stripped
// outputs), and the tool-output truncation boundary at
// sessionCompactionToolOutputMaxChars.
func TestFormatSessionCompactionToolCall(t *testing.T) {
const toolOutputBudget = sessionCompactionToolOutputMaxChars // 1200
longOutput := strings.Repeat("a", toolOutputBudget+100)
boundaryOutput := strings.Repeat("a", toolOutputBudget)
truncatedTail := "\n[truncated]"
cases := []struct {
name string
toolCall ToolCall
want string
}{
{
name: "zero value renders empty name and default input object literal",
toolCall: ToolCall{},
want: "Tool call: {}",
},
{
name: "name is trimmed and default input object literal is kept",
toolCall: ToolCall{
Name: " list_pods ",
},
want: "Tool call: list_pods {}",
},
{
name: "nil input leaves default object literal",
toolCall: ToolCall{
Name: "ping",
Input: nil,
},
want: "Tool call: ping {}",
},
{
name: "empty input map leaves default object literal",
toolCall: ToolCall{
Name: "ping",
Input: map[string]interface{}{},
},
want: "Tool call: ping {}",
},
{
name: "single key input is marshaled to compact json",
toolCall: ToolCall{
Name: "read_file",
Input: map[string]interface{}{"path": "/etc/hosts"},
},
want: `Tool call: read_file {"path":"/etc/hosts"}`,
},
{
name: "multiple input keys are emitted in sorted json key order",
toolCall: ToolCall{
Name: "provision",
Input: map[string]interface{}{
"zebra": "1",
"apple": "2",
"mango": "3",
},
},
want: `Tool call: provision {"apple":"2","mango":"3","zebra":"1"}`,
},
{
name: "secret bearing input value is redacted after marshal",
toolCall: ToolCall{
Name: "login",
Input: map[string]interface{}{
"api_key": "sk-live-secret-value",
},
},
want: `Tool call: login {"api_key":"[REDACTED]"}`,
},
{
name: "input that fails to marshal falls back to default object literal",
toolCall: ToolCall{
Name: "broken",
Input: map[string]interface{}{"bad": make(chan int)},
},
want: "Tool call: broken {}",
},
{
name: "output only is sanitized and rendered on its own line",
toolCall: ToolCall{
Output: "pod-1234 running",
},
want: "Tool call: {}\nTool output: pod-1234 running",
},
{
name: "whitespace only output produces no output line",
toolCall: ToolCall{
Name: "noop",
Output: " \n\t ",
},
want: "Tool call: noop {}",
},
{
name: "empty output produces no output line",
toolCall: ToolCall{
Name: "noop",
Output: "",
},
want: "Tool call: noop {}",
},
{
name: "secret bearing output value is redacted",
toolCall: ToolCall{
Name: "fetch",
Output: "api_key: sk-live-secret-value",
},
want: "Tool call: fetch {}\nTool output: api_key: [REDACTED]",
},
{
name: "decorative emoji prefix in output is stripped by sanitization",
toolCall: ToolCall{
Output: "🟢 All good",
},
want: "Tool call: {}\nTool output: All good",
},
{
name: "output surrounding whitespace is trimmed",
toolCall: ToolCall{
Output: "\n health: ok \n",
},
want: "Tool call: {}\nTool output: health: ok",
},
{
name: "output exceeding the char budget is truncated",
toolCall: ToolCall{
Output: longOutput,
},
want: "Tool call: {}\nTool output: " + boundaryOutput + truncatedTail,
},
{
name: "output exactly at the char budget is not truncated",
toolCall: ToolCall{
Output: boundaryOutput,
},
want: "Tool call: {}\nTool output: " + boundaryOutput,
},
{
name: "name input and output are all rendered together",
toolCall: ToolCall{
Name: "run_check",
Input: map[string]interface{}{"host": "vault.lan"},
Output: "ok",
},
want: `Tool call: run_check {"host":"vault.lan"}` + "\nTool output: ok",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := formatSessionCompactionToolCall(tc.toolCall)
require.Equal(t, tc.want, got)
})
}
}
// TestFormatSessionCompactionToolCallDeterminism asserts that repeated calls
// with the same input produce byte-identical output (json.Marshal key sorting
// and redaction are stable).
func TestFormatSessionCompactionToolCallDeterminism(t *testing.T) {
toolCall := ToolCall{
Name: "deploy",
Input: map[string]interface{}{"image": "nginx:1.25", "replicas": 3},
Output: "deployments/apps Deployment created",
}
first := formatSessionCompactionToolCall(toolCall)
for i := 0; i < 10; i++ {
require.Equal(t, first, formatSessionCompactionToolCall(toolCall),
"iteration %d produced non-deterministic output", i)
}
want := `Tool call: deploy {"image":"nginx:1.25","replicas":3}` + "\nTool output: deployments/apps Deployment created"
require.Equal(t, want, first)
}
-182
View File
@@ -1,182 +0,0 @@
package chat
import (
"context"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func seedCompactionSession(t *testing.T, store *SessionStore, sessionID string) {
t.Helper()
for turn := 1; turn <= 5; turn++ {
require.NoError(t, store.AddMessage(sessionID, Message{
ID: "u-compaction-test",
Role: "user",
Content: "Prompt turn " + string(rune('0'+turn)),
}))
require.NoError(t, store.AddMessage(sessionID, Message{
ID: "a-compaction-test",
Role: "assistant",
Content: "Answer turn " + string(rune('0'+turn)),
}))
}
}
func TestSessionStoreCompactWithSummaryRewritesOldTurns(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
seedCompactionSession(t, store, session.ID)
result, err := store.CompactWithSummary(session.ID, "The earlier turns inspected storage health.", 4, "openrouter:test-model")
require.NoError(t, err)
require.True(t, result.Success)
require.Equal(t, "compacted", result.Status)
require.Equal(t, 10, result.OriginalMessageCount)
require.Equal(t, 9, result.CompactedMessageCount)
require.Equal(t, 2, result.CompactedMessages)
require.Equal(t, 8, result.KeptRecentMessages)
messages, err := store.GetMessages(session.ID)
require.NoError(t, err)
require.Len(t, messages, 9)
require.Equal(t, "assistant", messages[0].Role)
require.Contains(t, messages[0].Content, "Session compacted.")
require.Contains(t, messages[0].Content, "The earlier turns inspected storage health.")
require.Equal(t, "openrouter:test-model", messages[0].Model)
require.Equal(t, "Prompt turn 2", messages[1].Content)
require.NotContains(t, strings.Join(messageContents(messages), "\n"), "Prompt turn 1")
}
func TestServiceSummarizeSessionUsesProviderAndCompactsTranscript(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
seedCompactionSession(t, store, session.ID)
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "secret-turn",
Role: "user",
Content: "api_key: sk-live-secret-value",
}))
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "secret-answer",
Role: "assistant",
Content: "Acknowledged.",
}))
provider := &MockProvider{}
provider.On("Chat", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
if !strings.Contains(req.System, "compact Pulse Assistant chat sessions") {
return false
}
if len(req.Messages) != 1 {
return false
}
prompt := req.Messages[0].Content
return strings.Contains(prompt, "Prompt turn 1") &&
strings.Contains(prompt, "[REDACTED]") &&
!strings.Contains(prompt, "sk-live-secret-value")
})).Return(&providers.ChatResponse{
Content: "Earlier turns established storage health and a final secret-bearing prompt was redacted.",
Model: "openrouter:test-model",
InputTokens: 42,
OutputTokens: 24,
}, nil).Once()
provider.On("Name").Return("mock-provider").Maybe()
service := &Service{
started: true,
sessions: store,
provider: provider,
}
result, err := service.SummarizeSession(context.Background(), session.ID)
require.NoError(t, err)
require.Equal(t, true, result["success"])
require.Equal(t, "compacted", result["status"])
require.Equal(t, session.ID, result["session_id"])
messages, err := store.GetMessages(session.ID)
require.NoError(t, err)
require.Contains(t, messages[0].Content, "Earlier turns established storage health")
require.NotContains(t, messages[0].Content, "sk-live-secret-value")
provider.AssertExpectations(t)
}
func TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
seedCompactionSession(t, store, session.ID)
// The persisted transcript carries the raw resource hostname the user typed,
// regardless of how the live turn was redacted. Compaction must not ship it
// to a cloud model.
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "vault-turn",
Role: "user",
Content: "check vault.lan health please",
}))
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "vault-answer",
Role: "assistant",
Content: "Acknowledged.",
}))
unifiedProvider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
unifiedresources.ResourceTypeAgent: {{
ID: "agent/vault",
Name: "vault",
Type: unifiedresources.ResourceTypeAgent,
Status: unifiedresources.StatusOnline,
Tags: []string{"secret"}, // -> Restricted -> identifiers redacted
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"vault.lan"}},
}},
}}
var capturedPrompt string
provider := &MockProvider{}
provider.On("Chat", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
if len(req.Messages) == 1 {
capturedPrompt = req.Messages[0].Content
}
return true
})).Return(&providers.ChatResponse{
Content: "Compacted summary.",
Model: "openrouter:test-model",
}, nil).Once()
provider.On("Name").Return("mock-provider").Maybe()
service := &Service{
started: true,
sessions: store,
provider: provider,
cfg: &config.AIConfig{ChatModel: "openrouter:test-model"},
unifiedResourceProvider: unifiedProvider,
}
_, err = service.SummarizeSession(context.Background(), session.ID)
require.NoError(t, err)
require.NotEmpty(t, capturedPrompt)
// The transcript reached the model (proves the turn ran) but the resource
// hostname was redacted at the model boundary by the privacy dial.
require.Contains(t, capturedPrompt, "Prompt turn 1")
require.NotContains(t, capturedPrompt, "vault.lan")
provider.AssertExpectations(t)
}
func messageContents(messages []Message) []string {
contents := make([]string, 0, len(messages))
for _, message := range messages {
contents = append(contents, message.Content)
}
return contents
}
-199
View File
@@ -187,205 +187,6 @@ func TestSessionStore(t *testing.T) {
})
}
func TestSessionStoreForkClonesTranscriptAndHandoffSummary(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
source, err := store.Create()
require.NoError(t, err)
require.NoError(t, store.AddMessage(source.ID, Message{
ID: "user-1",
Role: "user",
Content: "Inspect the storage warning",
}))
require.NoError(t, store.AddMessage(source.ID, Message{
ID: "assistant-1",
Role: "assistant",
Content: "I found the warning.",
ToolCalls: []ToolCall{
{
ID: "tool-1",
Name: "pulse_query",
Input: map[string]interface{}{"action": "resource"},
},
},
}))
require.NoError(t, store.SetModelHandoffEnvelope(
source.ID,
"finding-storage-1",
"private model handoff context must stay model-only",
[]HandoffResource{{ID: "storage:pool-a", Name: "pool-a", Type: "storage", Node: "nas-a"}},
[]HandoffAction{{
FindingID: "finding-storage-1",
ApprovalID: "approval-1",
ApprovalStatus: "pending",
Description: "Review storage warning",
}},
HandoffMetadata{},
))
fork, err := store.Fork(source.ID)
require.NoError(t, err)
require.NotNil(t, fork)
assert.NotEqual(t, source.ID, fork.ID)
assert.Equal(t, "Fork of Inspect the storage warning", fork.Title)
assert.Equal(t, 2, fork.MessageCount)
require.NotNil(t, fork.HandoffSummary)
assert.Equal(t, sessionHandoffKindPatrolFinding, fork.HandoffSummary.Kind)
assert.Equal(t, "finding-storage-1", fork.HandoffSummary.FindingID)
assert.Equal(t, 1, fork.HandoffSummary.ResourceCount)
assert.Equal(t, 1, fork.HandoffSummary.ActionCount)
assert.True(t, fork.HandoffSummary.RequiresApproval)
require.NotNil(t, fork.HandoffSummary.PrimaryResource)
assert.Equal(t, "pool-a", fork.HandoffSummary.PrimaryResource.Name)
forkMessages, err := store.GetMessages(fork.ID)
require.NoError(t, err)
require.Len(t, forkMessages, 2)
assert.Equal(t, "user-1", forkMessages[0].ID)
assert.Equal(t, "Inspect the storage warning", forkMessages[0].Content)
require.Len(t, forkMessages[1].ToolCalls, 1)
assert.Equal(t, "tool-1", forkMessages[1].ToolCalls[0].ID)
require.NoError(t, store.AddMessage(fork.ID, Message{Role: "user", Content: "Continue here"}))
sourceMessages, err := store.GetMessages(source.ID)
require.NoError(t, err)
require.Len(t, sourceMessages, 2)
}
func TestSessionStoreUndoRedoLastTurn(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
require.NoError(t, store.AddMessage(session.ID, Message{ID: "u1", Role: "user", Content: "First prompt"}))
require.NoError(t, store.AddMessage(session.ID, Message{ID: "a1", Role: "assistant", Content: "First answer"}))
require.NoError(t, store.AddMessage(session.ID, Message{ID: "u2", Role: "user", Content: "Second prompt"}))
require.NoError(t, store.AddMessage(session.ID, Message{ID: "a2", Role: "assistant", Content: "Second answer"}))
undo, err := store.UndoLastTurn(session.ID)
require.NoError(t, err)
require.NotNil(t, undo)
assert.True(t, undo.Success)
assert.Equal(t, "Second prompt", undo.RestoredPrompt)
assert.Equal(t, 2, undo.RemovedMessages)
assert.True(t, undo.CanRedo)
summary, err := store.Get(session.ID)
require.NoError(t, err)
assert.True(t, summary.CanRedo)
messages, err := store.GetMessages(session.ID)
require.NoError(t, err)
require.Len(t, messages, 2)
assert.Equal(t, "u1", messages[0].ID)
assert.Equal(t, "a1", messages[1].ID)
undo, err = store.UndoLastTurn(session.ID)
require.NoError(t, err)
assert.True(t, undo.Success)
assert.Equal(t, "First prompt", undo.RestoredPrompt)
messages, err = store.GetMessages(session.ID)
require.NoError(t, err)
assert.Empty(t, messages)
redo, err := store.RedoLastTurn(session.ID)
require.NoError(t, err)
require.NotNil(t, redo)
assert.True(t, redo.Success)
assert.Equal(t, 2, redo.RestoredMessages)
assert.True(t, redo.CanRedo)
messages, err = store.GetMessages(session.ID)
require.NoError(t, err)
require.Len(t, messages, 2)
assert.Equal(t, "First prompt", messages[0].Content)
redo, err = store.RedoLastTurn(session.ID)
require.NoError(t, err)
assert.True(t, redo.Success)
assert.False(t, redo.CanRedo)
summary, err = store.Get(session.ID)
require.NoError(t, err)
assert.False(t, summary.CanRedo)
messages, err = store.GetMessages(session.ID)
require.NoError(t, err)
require.Len(t, messages, 4)
assert.Equal(t, "Second prompt", messages[2].Content)
undo, err = store.UndoLastTurn(session.ID)
require.NoError(t, err)
assert.True(t, undo.CanRedo)
summary, err = store.Get(session.ID)
require.NoError(t, err)
assert.True(t, summary.CanRedo)
require.NoError(t, store.AddMessage(session.ID, Message{ID: "u3", Role: "user", Content: "Replacement"}))
summary, err = store.Get(session.ID)
require.NoError(t, err)
assert.False(t, summary.CanRedo)
redo, err = store.RedoLastTurn(session.ID)
require.NoError(t, err)
assert.False(t, redo.Success)
assert.False(t, redo.CanRedo)
assert.Equal(t, "No undone turn to redo.", redo.Message)
}
func TestSessionStoreUndoLastTurnExpectedPromptGuard(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
require.NoError(t, store.AddMessage(session.ID, Message{ID: "u1", Role: "user", Content: "First prompt"}))
require.NoError(t, store.AddMessage(session.ID, Message{ID: "a1", Role: "assistant", Content: "First answer"}))
// A retry whose prompt never reached the session must not remove the
// previous turn.
undo, err := store.UndoLastTurnWithOptions(session.ID, SessionTurnUndoOptions{
ExpectedPrompt: "Prompt that never persisted",
})
require.NoError(t, err)
require.NotNil(t, undo)
assert.False(t, undo.Success)
assert.Equal(t, "The latest turn no longer matches the prompt being retried.", undo.Message)
messages, err := store.GetMessages(session.ID)
require.NoError(t, err)
require.Len(t, messages, 2)
// A matching prompt (modulo surrounding whitespace) removes the turn.
undo, err = store.UndoLastTurnWithOptions(session.ID, SessionTurnUndoOptions{
ExpectedPrompt: " First prompt ",
})
require.NoError(t, err)
assert.True(t, undo.Success)
assert.Equal(t, "First prompt", undo.RestoredPrompt)
messages, err = store.GetMessages(session.ID)
require.NoError(t, err)
assert.Empty(t, messages)
}
func TestSessionStoreForkRejectsMissingOrInvalidSession(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
_, err = store.Fork("../bad")
require.Error(t, err)
_, err = store.Fork("missing-session")
require.Error(t, err)
assert.Contains(t, err.Error(), "session not found")
}
func TestSessionStoreMigratesLegacySessionFileOnWrite(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
+1 -93
View File
@@ -19,7 +19,6 @@ type Session struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageCount int `json:"message_count,omitempty"`
CanRedo bool `json:"can_redo,omitempty"`
System bool `json:"system,omitempty"`
HandoffSummary *SessionHandoffSummary `json:"handoff_summary,omitempty"`
}
@@ -34,36 +33,6 @@ func IsSystemSessionID(id string) bool {
return id == "patrol-main" || id == "patrol-eval" || strings.HasPrefix(id, "investigation-")
}
// SessionTurnUndoResult is returned when the chat runtime removes the latest
// user turn and makes that prompt available for editing.
type SessionTurnUndoResult struct {
Success bool `json:"success"`
SessionID string `json:"session_id"`
RestoredPrompt string `json:"restored_prompt,omitempty"`
RemovedMessages int `json:"removed_messages,omitempty"`
CanRedo bool `json:"can_redo"`
Message string `json:"message,omitempty"`
}
// SessionTurnUndoOptions controls how the latest user turn is removed.
type SessionTurnUndoOptions struct {
// ExpectedPrompt, when non-empty, only removes the turn if the latest
// user prompt matches it, so a retry can never remove a different turn
// (e.g. when the failed send never reached the session). The removed
// turn stays redoable until the retried send lands, at which point
// AddMessage clears the redo stack like any other new message.
ExpectedPrompt string `json:"expected_prompt"`
}
// SessionTurnRedoResult is returned when a previously undone turn is restored.
type SessionTurnRedoResult struct {
Success bool `json:"success"`
SessionID string `json:"session_id"`
RestoredMessages int `json:"restored_messages,omitempty"`
CanRedo bool `json:"can_redo"`
Message string `json:"message,omitempty"`
}
// SessionHandoffSummary is the browser-safe public summary of private
// session-scoped model handoff metadata. It intentionally omits model-only
// context text and raw remediation details.
@@ -94,12 +63,7 @@ type Message struct {
ToolCalls []ToolCall `json:"tool_calls"`
ToolResult *ToolResult `json:"tool_result,omitempty"`
Model string `json:"model,omitempty"`
// Steered marks a user message that was injected into a running agentic
// loop at a turn boundary (mid-turn steering) rather than opening a new
// turn. Steered user messages are persisted by the end-of-run save,
// unlike the turn-opening user prompt which is saved before the loop.
Steered bool `json:"steered,omitempty"`
Timestamp time.Time `json:"timestamp"`
Timestamp time.Time `json:"timestamp"`
}
func EmptyMessage() Message {
@@ -291,12 +255,6 @@ type ExecuteRequest struct {
SuppressSessionEvent bool `json:"-"`
}
// QuestionAnswer represents a user's answer to a question
type QuestionAnswer struct {
ID string `json:"id"`
Value string `json:"value"`
}
// ContentData is the data for "content" events
type ContentData struct {
Text string `json:"text"`
@@ -396,61 +354,11 @@ type ApprovalNeededData struct {
Preflight *ApprovalPreflightData `json:"preflight,omitempty"`
}
// QuestionData is the data for "question" events
type QuestionData struct {
SessionID string `json:"session_id,omitempty"`
QuestionID string `json:"question_id"`
Questions []Question `json:"questions"`
}
// Question represents a question from the AI to the user
type Question struct {
ID string `json:"id"`
Type string `json:"type,omitempty"` // "text" | "select"
Question string `json:"question"`
Header string `json:"header,omitempty"`
Options []QuestionOption `json:"options,omitempty"`
}
// QuestionOption represents an option for a question
type QuestionOption struct {
Label string `json:"label"`
Value string `json:"value,omitempty"`
Description string `json:"description,omitempty"`
}
// ErrorData is the data for "error" events
type ErrorData struct {
Message string `json:"message"`
}
// SteerAppliedData is the data for "steer_applied" events: a steering
// message was injected into the running loop at a turn boundary. The
// originating client reconciles its pending row via ClientMessageID; other
// clients on the same session render Prompt as a new steered user row.
type SteerAppliedData struct {
SessionID string `json:"session_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
ClientMessageID string `json:"client_message_id,omitempty"`
Prompt string `json:"prompt,omitempty"`
Turn int `json:"turn"`
}
// SessionSteerRequest is the payload for POST /api/ai/sessions/{id}/steer.
type SessionSteerRequest struct {
Prompt string `json:"prompt"`
ClientMessageID string `json:"client_message_id,omitempty"`
}
// SessionSteerResult reports whether a steering message reached a running
// loop. accepted=false with a reason is a normal outcome (e.g. the run
// finished first); the client falls back to the ordinary queue drain.
type SessionSteerResult struct {
Accepted bool `json:"accepted"`
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"` // "no_active_run" | "system_session" | "empty_prompt" | "steer_backlog"
}
// DoneData is the data for "done" events
type DoneData struct {
SessionID string `json:"session_id,omitempty"`
-12
View File
@@ -12,15 +12,3 @@ func (e *PulseToolExecutor) AssistantProviderTools(opts agentcapabilities.Assist
}
return agentcapabilities.ProjectPulseAssistantProviderTools(agentcapabilities.CanonicalManifest(), e.ListTools(), e.ListToolGovernance(), opts)
}
// AssistantSurfaceToolContract returns the native Assistant surface summary
// over the shared Pulse Intelligence Core. The executor owns the runtime
// registry and availability rules; agentcapabilities owns the surface projection
// contract shared with MCP and future external-agent adapters.
func (e *PulseToolExecutor) AssistantSurfaceToolContract(opts agentcapabilities.AssistantProviderToolOptions) agentcapabilities.SurfaceToolContract {
contract, _ := agentcapabilities.ProjectPulseAssistantSurfaceToolContract(
agentcapabilities.CanonicalManifest().SurfaceContract,
e.AssistantProviderTools(opts),
)
return contract
}
+11 -56
View File
@@ -48,11 +48,14 @@ func TestPulseToolExecutorAssistantProviderToolsUsesRuntimeAvailability(t *testi
projected := exec.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{IncludeQuestionTool: true})
names := providerToolNameSet(projected)
for _, name := range []string{agentcapabilities.PulseQueryToolName, agentcapabilities.PulseReadToolName, agentcapabilities.PulseControlToolName, agentcapabilities.PulseQuestionToolName} {
for _, name := range []string{agentcapabilities.PulseQueryToolName, agentcapabilities.PulseReadToolName, agentcapabilities.PulseControlToolName} {
if !names[name] {
t.Fatalf("AssistantProviderTools did not expose available tool %s; names=%v", name, names)
}
}
if names[agentcapabilities.PulseQuestionToolName] {
t.Fatalf("canonical Assistant surface declares no interactive questions; names=%v", names)
}
if names[agentcapabilities.PulseFileEditToolName] {
t.Fatal("AssistantProviderTools exposed retired file mutation tool")
}
@@ -140,9 +143,12 @@ func TestPulseToolExecutorAssistantProviderToolsEnterThroughManifestSurfaceAffor
func TestPulseToolExecutorAssistantProviderToolsHonorsInteractionMode(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{ReadState: &fakeReadState{}, ControlLevel: ControlLevelControlled})
// The canonical Assistant surface declares no interactive questions, so
// even an interactive request cannot add pulse_question: no Pulse client
// can answer it since the web chat window was retired.
interactive := providerToolNameSet(exec.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{IncludeQuestionTool: true}))
if !interactive[agentcapabilities.PulseQuestionToolName] {
t.Fatal("interactive Assistant provider surface must include the question tool")
if interactive[agentcapabilities.PulseQuestionToolName] {
t.Fatal("Assistant provider surface must not include the question tool")
}
nonInteractive := providerToolNameSet(exec.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{}))
@@ -168,52 +174,6 @@ func TestPulseToolExecutorAssistantProviderToolsHonorsControlLevel(t *testing.T)
}
}
func TestPulseToolExecutorAssistantSurfaceToolContractUsesRuntimeProjection(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{
ReadState: &fakeReadState{},
TypedActionPlanner: projectionTestPlanner(t),
AgentServer: &mockAgentServer{agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "node-1"}}},
ControlLevel: ControlLevelControlled,
})
contract := exec.AssistantSurfaceToolContract(agentcapabilities.AssistantProviderToolOptions{IncludeQuestionTool: true})
names := map[string]bool{}
for _, name := range contract.ToolNames {
names[name] = true
}
registryNames := map[string]bool{}
for _, name := range contract.RegistryToolNames {
registryNames[name] = true
}
nativeNames := map[string]bool{}
for _, name := range contract.NativeToolNames {
nativeNames[name] = true
}
if contract.SurfaceID != agentcapabilities.SurfaceIDPulseAssistant {
t.Fatalf("Assistant surface id = %q", contract.SurfaceID)
}
if contract.ToolSource != agentcapabilities.SurfaceToolSourceAssistantRegistry {
t.Fatalf("Assistant surface tool source = %q", contract.ToolSource)
}
for _, name := range []string{agentcapabilities.PulseQueryToolName, agentcapabilities.PulseReadToolName, agentcapabilities.PulseControlToolName, agentcapabilities.PulseQuestionToolName} {
if !names[name] {
t.Fatalf("Assistant surface contract missing %s; names=%v", name, names)
}
}
for _, name := range []string{agentcapabilities.PulseQueryToolName, agentcapabilities.PulseReadToolName, agentcapabilities.PulseControlToolName} {
if !registryNames[name] {
t.Fatalf("Assistant registry contract missing %s; names=%v", name, registryNames)
}
}
if !nativeNames[agentcapabilities.PulseQuestionToolName] {
t.Fatalf("Assistant native contract missing question tool; names=%v", nativeNames)
}
if len(contract.CapabilityNames) != 0 {
t.Fatalf("Assistant surface contract must not duplicate MCP capability names: %+v", contract.CapabilityNames)
}
}
// Retired execution transports alone must not advertise canonical planning on
// either provider-facing projection, even when execution authority is enabled.
func TestAssistantProjectionsRequireTypedPlanner(t *testing.T) {
@@ -234,13 +194,8 @@ func TestAssistantProjectionsRequireTypedPlanner(t *testing.T) {
})
opts := agentcapabilities.AssistantProviderToolOptions{}
names := providerToolNameSet(exec.AssistantProviderTools(opts))
contract := exec.AssistantSurfaceToolContract(opts)
found := false
for _, name := range contract.ToolNames {
found = found || name == agentcapabilities.PulseControlToolName
}
if names[agentcapabilities.PulseControlToolName] != tc.want || found != tc.want {
t.Fatalf("provider control=%v surface control=%v want=%v", names[agentcapabilities.PulseControlToolName], found, tc.want)
if names[agentcapabilities.PulseControlToolName] != tc.want {
t.Fatalf("provider control=%v want=%v", names[agentcapabilities.PulseControlToolName], tc.want)
}
})
}
-347
View File
@@ -15,7 +15,6 @@ import (
"fmt"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
airuntime "github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
@@ -57,13 +56,6 @@ type AIService interface {
GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error)
ClearModelHandoffContext(ctx context.Context, sessionID string) error
AbortSession(ctx context.Context, sessionID string) error
SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error)
ForkSession(ctx context.Context, sessionID string) (*chat.Session, error)
UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error)
RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error)
SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error)
AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error
AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract
SetAlertProvider(provider chat.AssistantAlertProvider)
SetFindingsProvider(provider chat.AssistantFindingsProvider)
SetBaselineProvider(provider chat.AssistantBaselineProvider)
@@ -1030,21 +1022,11 @@ type ChatRequest struct {
HandoffMetadata chat.HandoffMetadata `json:"handoff_metadata,omitempty"`
}
type AssistantWorkflowPromptRenderRequest struct {
Name string `json:"name"`
Arguments map[string]string `json:"arguments,omitempty"`
}
type AssistantWorkflowPromptActivityRequest struct {
Name string `json:"name"`
Surface string `json:"surface,omitempty"`
}
type AssistantWorkflowPromptRenderResponse struct {
Description string `json:"description"`
Text string `json:"text"`
}
type RenameSessionRequest struct {
Title string `json:"title"`
}
@@ -3020,98 +3002,6 @@ func (h *AIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(status)
}
// HandleAssistantSurfaceTools handles GET /api/ai/assistant/surface-tools.
func (h *AIHandler) HandleAssistantSurfaceTools(w http.ResponseWriter, r *http.Request) {
if cfg := h.getConfig(r.Context()); cfg != nil {
applyConfiguredCORSHeaders(
w,
r.Header.Get("Origin"),
cfg.AllowedOrigins,
"GET, OPTIONS",
"Accept, Cookie",
)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := chat.WithExecuteAuthority(r.Context(), assistantExecuteAuthority(r.Context()))
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(svc.AssistantSurfaceToolContract(ctx))
}
// HandleRenderWorkflowPrompt handles POST /api/ai/workflow-prompts/render.
func (h *AIHandler) HandleRenderWorkflowPrompt(w http.ResponseWriter, r *http.Request) {
if cfg := h.getConfig(r.Context()); cfg != nil {
applyConfiguredCORSHeaders(
w,
r.Header.Get("Origin"),
cfg.AllowedOrigins,
"POST, OPTIONS",
"Content-Type, Accept, Cookie",
)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AssistantWorkflowPromptRenderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
manifest := agentcapabilities.CanonicalManifest()
result, err := agentcapabilities.BuildPulseWorkflowPromptFromManifestWithOptions(
manifest,
agentcapabilities.PulseWorkflowPromptParams{
Name: req.Name,
Arguments: req.Arguments,
},
agentcapabilities.PulseWorkflowPromptRenderOptions{
ResourceContextInstruction: func(resourceID string) string {
return fmt.Sprintf("Use the Assistant's current Pulse context for resource %q; if it is not already attached, use the shared resource context capability before making a recommendation", resourceID)
},
},
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.recordAssistantWorkflowPromptActivity(r.Context(), req.Name)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(AssistantWorkflowPromptRenderResponse{
Description: result.Description,
Text: result.Text,
})
}
// HandleRecordWorkflowPromptActivity handles POST /api/ai/workflow-prompts/activity.
func (h *AIHandler) HandleRecordWorkflowPromptActivity(w http.ResponseWriter, r *http.Request) {
if cfg := h.getConfig(r.Context()); cfg != nil {
@@ -3179,10 +3069,6 @@ func normalizeFirstPartyWorkflowPromptActivitySurface(surface string) string {
}
}
func (h *AIHandler) recordAssistantWorkflowPromptActivity(ctx context.Context, promptName string) {
h.recordFirstPartyWorkflowPromptActivity(ctx, config.WorkflowPromptActivitySurfacePulseAssistant, promptName)
}
func (h *AIHandler) recordFirstPartyWorkflowPromptActivity(ctx context.Context, surface, promptName string) {
if h == nil || strings.TrimSpace(promptName) == "" {
return
@@ -3200,239 +3086,6 @@ func (h *AIHandler) recordFirstPartyWorkflowPromptActivity(ctx context.Context,
}
}
// HandleSummarize handles POST /api/ai/sessions/{id}/summarize
// Compresses context when nearing model limits
func (h *AIHandler) HandleSummarize(w http.ResponseWriter, r *http.Request, sessionID string) {
if !requireAssistantSessionMutationMethod(w, r) {
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
result, err := svc.SummarizeSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleDiff handles GET /api/ai/sessions/{id}/diff
// Rejects OpenCode-style file diff requests; Pulse sessions do not own code-file changes.
func (h *AIHandler) HandleDiff(w http.ResponseWriter, r *http.Request, sessionID string) {
writeAssistantSessionFileChangesUnsupported(w)
}
// HandleFork handles POST /api/ai/sessions/{id}/fork
// Creates a branch point in the conversation
func (h *AIHandler) HandleFork(w http.ResponseWriter, r *http.Request, sessionID string) {
if !requireAssistantSessionMutationMethod(w, r) {
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
session, err := svc.ForkSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// HandleUndoLastTurn handles POST /api/ai/sessions/{id}/undo.
// The body is optional; retry/regenerate sends SessionTurnUndoOptions with the
// prompt being re-run so a stale retry can never remove a different turn.
func (h *AIHandler) HandleUndoLastTurn(w http.ResponseWriter, r *http.Request, sessionID string) {
if !requireAssistantSessionMutationMethod(w, r) {
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
var opts chat.SessionTurnUndoOptions
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil && err != io.EOF {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
}
result, err := svc.UndoLastTurn(ctx, sessionID, opts)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleRedoLastTurn handles POST /api/ai/sessions/{id}/redo.
func (h *AIHandler) HandleRedoLastTurn(w http.ResponseWriter, r *http.Request, sessionID string) {
if !requireAssistantSessionMutationMethod(w, r) {
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
result, err := svc.RedoLastTurn(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleSteerSession handles POST /api/ai/sessions/{id}/steer.
// It routes a mid-turn steering message to the session's running agentic
// loop; accepted=false with a reason is a normal outcome and the client
// falls back to the ordinary follow-up queue.
func (h *AIHandler) HandleSteerSession(w http.ResponseWriter, r *http.Request, sessionID string) {
if !requireAssistantSessionMutationMethod(w, r) {
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
var steerReq chat.SessionSteerRequest
if err := json.NewDecoder(r.Body).Decode(&steerReq); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
result, err := svc.SteerSession(ctx, sessionID, steerReq)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleRevert handles POST /api/ai/sessions/{id}/revert
// Rejects OpenCode-style file revert requests; Pulse actions use governed history.
func (h *AIHandler) HandleRevert(w http.ResponseWriter, r *http.Request, sessionID string) {
writeAssistantSessionFileChangesUnsupported(w)
}
// HandleUnrevert handles POST /api/ai/sessions/{id}/unrevert
// Rejects OpenCode-style file unrevert requests; Pulse actions use governed history.
func (h *AIHandler) HandleUnrevert(w http.ResponseWriter, r *http.Request, sessionID string) {
writeAssistantSessionFileChangesUnsupported(w)
}
func writeAssistantSessionFileChangesUnsupported(w http.ResponseWriter) {
http.Error(
w,
"Pulse Assistant sessions do not own file diffs or file-change revert. Use governed action history for infrastructure changes.",
http.StatusNotImplemented,
)
}
// AnswerQuestionRequest represents a request to answer a question
type AnswerQuestionRequest struct {
Answers []struct {
ID string `json:"id"`
Value string `json:"value"`
} `json:"answers"`
}
// HandleAnswerQuestion handles POST /api/ai/question/{questionID}/answer
func (h *AIHandler) HandleAnswerQuestion(w http.ResponseWriter, r *http.Request, questionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
var req AnswerQuestionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Convert to chat.QuestionAnswer
answers := make([]chat.QuestionAnswer, len(req.Answers))
for i, a := range req.Answers {
answers[i] = chat.QuestionAnswer{
ID: a.ID,
Value: a.Value,
}
}
log.Info().
Str("questionID", questionID).
Int("answers_count", len(answers)).
Msg("AIHandler: Received answer to question")
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
if err := svc.AnswerQuestion(ctx, questionID, answers); err != nil {
log.Error().Err(err).Str("questionID", questionID).Msg("Failed to answer question")
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// SetAlertProvider sets the alert provider for Assistant tools.
func (h *AIHandler) SetAlertProvider(provider chat.AssistantAlertProvider) {
if svc := h.getDefaultService(); svc != nil {
@@ -6,7 +6,6 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
@@ -56,28 +55,7 @@ func (s *capturingAIService) GetModelHandoffMetadata(ctx context.Context, sessio
func (s *capturingAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error {
return nil
}
func (s *capturingAIService) AbortSession(ctx context.Context, sessionID string) error { return nil }
func (s *capturingAIService) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
return nil, nil
}
func (s *capturingAIService) ForkSession(ctx context.Context, sessionID string) (*chat.Session, error) {
return &chat.Session{ID: "fork"}, nil
}
func (s *capturingAIService) UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error) {
return &chat.SessionTurnUndoResult{Success: true, SessionID: sessionID}, nil
}
func (s *capturingAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) {
return &chat.SessionSteerResult{Accepted: false, SessionID: sessionID, Reason: "no_active_run"}, nil
}
func (s *capturingAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) {
return &chat.SessionTurnRedoResult{Success: true, SessionID: sessionID}, nil
}
func (s *capturingAIService) AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error {
return nil
}
func (s *capturingAIService) AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract {
return agentcapabilities.SurfaceToolContract{}
}
func (s *capturingAIService) AbortSession(ctx context.Context, sessionID string) error { return nil }
func (s *capturingAIService) SetAlertProvider(provider chat.AssistantAlertProvider) {}
func (s *capturingAIService) SetFindingsProvider(provider chat.AssistantFindingsProvider) {}
func (s *capturingAIService) SetBaselineProvider(provider chat.AssistantBaselineProvider) {}
-332
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -197,56 +196,6 @@ func (m *MockAIService) AbortSession(ctx context.Context, sessionID string) erro
return args.Error(0)
}
func (m *MockAIService) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
args := m.Called(ctx, sessionID)
return args.Get(0).(map[string]interface{}), args.Error(1)
}
func (m *MockAIService) ForkSession(ctx context.Context, sessionID string) (*chat.Session, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.Session), args.Error(1)
}
func (m *MockAIService) UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error) {
args := m.Called(ctx, sessionID, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.SessionTurnUndoResult), args.Error(1)
}
func (m *MockAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) {
args := m.Called(ctx, sessionID, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.SessionSteerResult), args.Error(1)
}
func (m *MockAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.SessionTurnRedoResult), args.Error(1)
}
func (m *MockAIService) AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error {
args := m.Called(ctx, questionID, answers)
return args.Error(0)
}
func (m *MockAIService) AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract {
args := m.Called(ctx)
if args.Get(0) == nil {
return agentcapabilities.SurfaceToolContract{}
}
return args.Get(0).(agentcapabilities.SurfaceToolContract)
}
func (m *MockAIService) SetAlertProvider(provider chat.AssistantAlertProvider) { m.Called(provider) }
func (m *MockAIService) SetFindingsProvider(provider chat.AssistantFindingsProvider) {
m.Called(provider)
@@ -597,74 +546,6 @@ func TestHandleStatus(t *testing.T) {
assert.Equal(t, "direct", resp["engine"])
}
func TestHandleAssistantSurfaceTools_UsesRuntimeSurfaceContract(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
want := agentcapabilities.SurfaceToolContract{
SurfaceID: agentcapabilities.SurfaceIDPulseAssistant,
SurfaceLabel: "Pulse Assistant",
ToolSource: agentcapabilities.SurfaceToolSourceAssistantRegistry,
ToolNames: []string{agentcapabilities.PulseQueryToolName, agentcapabilities.PulseQuestionToolName},
RegistryToolNames: []string{agentcapabilities.PulseQueryToolName},
NativeToolNames: []string{agentcapabilities.PulseQuestionToolName},
Affordances: agentcapabilities.SurfaceAffordanceContract{
Tools: true,
InteractiveQuestions: true,
},
}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("AssistantSurfaceToolContract", mock.Anything).Return(want)
req := httptest.NewRequest(http.MethodGet, "/api/ai/assistant/surface-tools", nil)
w := httptest.NewRecorder()
h.HandleAssistantSurfaceTools(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
var got agentcapabilities.SurfaceToolContract
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, want.SurfaceID, got.SurfaceID)
assert.Equal(t, want.ToolSource, got.ToolSource)
assert.Equal(t, want.ToolNames, got.ToolNames)
assert.Equal(t, want.RegistryToolNames, got.RegistryToolNames)
assert.Equal(t, want.NativeToolNames, got.NativeToolNames)
assert.Empty(t, got.CapabilityNames)
assert.True(t, got.Affordances.InteractiveQuestions)
mockSvc.AssertExpectations(t)
}
func TestHandleAssistantSurfaceTools_RequiresRunningAssistant(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(false)
req := httptest.NewRequest(http.MethodGet, "/api/ai/assistant/surface-tools", nil)
w := httptest.NewRecorder()
h.HandleAssistantSurfaceTools(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "Pulse Assistant is not running")
mockSvc.AssertExpectations(t)
mockSvc.AssertNotCalled(t, "AssistantSurfaceToolContract", mock.Anything)
}
func TestHandleAssistantSurfaceTools_MethodNotAllowed(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/assistant/surface-tools", nil)
w := httptest.NewRecorder()
h.HandleAssistantSurfaceTools(w, req)
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
}
func TestHandleSessions(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
@@ -877,49 +758,6 @@ func TestHandleChat_InvalidJSON(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandleRenderWorkflowPrompt_UsesSharedPulseIntelligenceRenderer(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
body := fmt.Sprintf(
`{"name":%q,"arguments":{"resourceId":"vm:101"}}`,
agentcapabilities.PulseWorkflowPromptInvestigateResource,
)
req := httptest.NewRequest(http.MethodPost, "/api/ai/workflow-prompts/render", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleRenderWorkflowPrompt(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
var got AssistantWorkflowPromptRenderResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal render response: %v", err)
}
assert.Equal(t, "Pulse resource investigation", got.Description)
assert.Contains(t, got.Text, `Investigate Pulse resource "vm:101"`)
assert.Contains(t, got.Text, "Use the Assistant's current Pulse context")
assert.Contains(t, got.Text, "shared resource context capability")
}
func TestHandleRenderWorkflowPrompt_RecordsWorkflowPromptActivity(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
h := newTestAIHandler(&config.Config{}, persistence, nil)
body := fmt.Sprintf(`{"name":%q}`, agentcapabilities.PulseWorkflowPromptOperationsLoop)
req := httptest.NewRequest(http.MethodPost, "/api/ai/workflow-prompts/render", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleRenderWorkflowPrompt(w, req)
assert.Equal(t, http.StatusOK, w.Code)
history, err := persistence.LoadWorkflowPromptActivityHistory()
require.NoError(t, err)
require.Len(t, history.Events, 1)
assert.Equal(t, config.WorkflowPromptActivitySurfacePulseAssistant, history.Events[0].Surface)
assert.Equal(t, agentcapabilities.PulseWorkflowPromptOperationsLoop, history.Events[0].PromptName)
}
func TestHandleRecordWorkflowPromptActivity_RecordsFirstPartyActivationStarter(t *testing.T) {
for _, surface := range []string{
config.WorkflowPromptActivitySurfacePulsePatrol,
@@ -984,53 +822,6 @@ func TestHandleRecordWorkflowPromptActivity_RejectsExternalAgentSurfaces(t *test
assert.Contains(t, w.Body.String(), "Unknown workflow prompt surface")
}
func TestHandleRenderWorkflowPrompt_UsesManifestWorkflowPromptRenderer(t *testing.T) {
source, err := os.ReadFile("ai_handler.go")
require.NoError(t, err)
text := string(source)
assert.Contains(t, text, "agentcapabilities.BuildPulseWorkflowPromptFromManifestWithOptions(")
assert.Contains(t, text, "manifest := agentcapabilities.CanonicalManifest()")
assert.NotContains(t, text, "agentcapabilities.BuildPulseWorkflowPromptWithOptions(\n\t\tagentcapabilities.CanonicalManifest().Capabilities")
}
func TestHandleRenderWorkflowPrompt_ValidatesSharedPromptArguments(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
body := fmt.Sprintf(`{"name":%q}`, agentcapabilities.PulseWorkflowPromptInvestigateResource)
req := httptest.NewRequest(http.MethodPost, "/api/ai/workflow-prompts/render", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleRenderWorkflowPrompt(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "prompt argument resourceId is required")
}
func TestHandleRenderWorkflowPrompt_RejectsUnknownSharedPrompt(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/workflow-prompts/render", strings.NewReader(`{"name":"pulse_unknown"}`))
w := httptest.NewRecorder()
h.HandleRenderWorkflowPrompt(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "unknown prompt: pulse_unknown")
}
func TestHandleRenderWorkflowPrompt_RejectsInvalidJSON(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/workflow-prompts/render", strings.NewReader("invalid"))
w := httptest.NewRecorder()
h.HandleRenderWorkflowPrompt(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid request")
}
func TestHandleChat_Success(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
@@ -2297,24 +2088,6 @@ func TestCanonicalizeChatMentionType_RejectsRemovedAliases(t *testing.T) {
assert.Equal(t, "", canonicalizeChatMentionType("app_container"))
}
func TestHandleAnswerQuestion(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("AnswerQuestion", mock.Anything, "q1", mock.Anything).Return(nil)
body := `{"answers": [{"id": "a1", "value": "v1"}]}`
req := httptest.NewRequest("POST", "/api/ai/question/q1/answer", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleAnswerQuestion(w, req, "q1")
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleSessions_NotRunning(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
@@ -2392,45 +2165,6 @@ func TestHandleAbort_Error(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandleSummarize_Error(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("SummarizeSession", mock.Anything, "s1").Return((map[string]interface{})(nil), assert.AnError)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/summarize", nil)
w := httptest.NewRecorder()
h.HandleSummarize(w, req, "s1")
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandleAnswerQuestion_InvalidJSON(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
req := httptest.NewRequest("POST", "/api/ai/question/q1/answer", strings.NewReader("invalid"))
w := httptest.NewRecorder()
h.HandleAnswerQuestion(w, req, "q1")
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandleAnswerQuestion_Error(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("AnswerQuestion", mock.Anything, "q1", mock.Anything).Return(assert.AnError)
body := `{"answers": []}`
req := httptest.NewRequest("POST", "/api/ai/question/q1/answer", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleAnswerQuestion(w, req, "q1")
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandleChat_Options(t *testing.T) {
h := newTestAIHandler(&config.Config{AllowedOrigins: "*"}, nil, nil)
req := httptest.NewRequest("OPTIONS", "/api/ai/chat", nil)
@@ -2549,46 +2283,6 @@ func TestHandleChat_BindsExecutionToRequestContext(t *testing.T) {
}
}
func TestHandleDiff_Unsupported(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest("GET", "/api/ai/sessions/s1/diff", nil)
w := httptest.NewRecorder()
h.HandleDiff(w, req, "s1")
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
func TestHandleFork_Error(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("ForkSession", mock.Anything, "s1").Return((*chat.Session)(nil), assert.AnError)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/fork", nil)
w := httptest.NewRecorder()
h.HandleFork(w, req, "s1")
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandleRevert_Unsupported(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/revert", nil)
w := httptest.NewRecorder()
h.HandleRevert(w, req, "s1")
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
func TestHandleUnrevert_Unsupported(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/unrevert", nil)
w := httptest.NewRecorder()
h.HandleUnrevert(w, req, "s1")
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
func TestHandleStatus_NotRunning(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
@@ -2661,32 +2355,6 @@ func TestHandleAbort_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleSummarize_Success(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("SummarizeSession", mock.Anything, "s1").Return(map[string]interface{}{"summary": "ok"}, nil)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/summarize", nil)
w := httptest.NewRecorder()
h.HandleSummarize(w, req, "s1")
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleFork_Success(t *testing.T) {
h := newTestAIHandler(&config.Config{}, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("ForkSession", mock.Anything, "s1").Return(&chat.Session{ID: "s2"}, nil)
req := httptest.NewRequest("POST", "/api/ai/sessions/s1/fork", nil)
w := httptest.NewRecorder()
h.HandleFork(w, req, "s1")
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleStatus_NoService(t *testing.T) {
// HandleStatus with no service initialized should still return 200 with running=false
cfg := &config.Config{}
+2 -39
View File
@@ -15,7 +15,6 @@ import (
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
@@ -886,32 +885,7 @@ func (s *sessionMutationRecordingAIService) AbortSession(ctx context.Context, se
return nil
}
func (s *sessionMutationRecordingAIService) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
s.record("summarize", sessionID)
return map[string]interface{}{"success": true}, nil
}
func (s *sessionMutationRecordingAIService) ForkSession(ctx context.Context, sessionID string) (*chat.Session, error) {
s.record("fork", sessionID)
return &chat.Session{ID: sessionID + "-fork"}, nil
}
func (s *sessionMutationRecordingAIService) UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error) {
s.record("undo", sessionID)
return &chat.SessionTurnUndoResult{Success: true, SessionID: sessionID}, nil
}
func (s *sessionMutationRecordingAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) {
s.record("redo", sessionID)
return &chat.SessionTurnRedoResult{Success: true, SessionID: sessionID}, nil
}
func (s *sessionMutationRecordingAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) {
s.record("steer", sessionID)
return &chat.SessionSteerResult{Accepted: true, SessionID: sessionID}, nil
}
var assistantSessionMutationSubresources = []string{"abort", "summarize", "fork", "undo", "redo", "steer"}
var assistantSessionMutationSubresources = []string{"abort"}
func TestAssistantSessionMutationsRequirePost(t *testing.T) {
for _, sub := range assistantSessionMutationSubresources {
@@ -947,11 +921,7 @@ func TestAssistantSessionMutationsRequirePost(t *testing.T) {
setUnexportedField(t, handler, "defaultService", svc)
router := &Router{aiHandler: handler}
body := ""
if sub == "steer" {
body = `{"prompt":"check the backup job too"}`
}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/"+sub, strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/"+sub, strings.NewReader(""))
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
@@ -978,13 +948,6 @@ func TestAssistantSessionReadSubresourcesKeepTheirMethods(t *testing.T) {
t.Fatalf("GET messages status = %d, want %d: %s", messagesRec.Code, http.StatusOK, messagesRec.Body.String())
}
diffReq := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/diff", nil)
diffRec := httptest.NewRecorder()
router.routeAISessions(diffRec, diffReq)
if diffRec.Code != http.StatusNotImplemented {
t.Fatalf("GET diff status = %d, want %d: %s", diffRec.Code, http.StatusNotImplemented, diffRec.Body.String())
}
if got := svc.recorded(); len(got) != 0 {
t.Fatalf("read sub-resources mutated the session: %v", got)
}
+25 -98
View File
@@ -14222,40 +14222,6 @@ func TestContract_ChatStreamEventJSONSnapshots(t *testing.T) {
}),
want: `{"type":"approval_needed","data":{"approval_id":"approval-1","tool_id":"tool-2","tool_name":"pulse_exec","command":"systemctl restart nginx","run_on_host":true,"target_host":"node-1","target_type":"agent","target_id":"agent-1","risk":"high","description":"Restart web service","audit_id":"action-1","plan":{"action_id":"action-1","request_id":"approval-1","summary":"Restart web service","requires_approval":true,"approval_policy":"admin","blast_radius":"service interruption on target","rollback_available":true,"plan_hash":"hash-1","expires_at":"2026-04-23T12:30:00Z"},"context_confidence":{"level":"verified","summary":"Target was resolved to a concrete resource before approval.","evidence":["Target identifier bound to agent-1."]},"preflight":{"target":"agent:node-1 (agent-1)","current_state":"Resolved approval target: agent:node-1 (agent-1).","intended_change":"Restart web service","dry_run_available":false,"dry_run_summary":"No provider-supported dry run is available for this action.","safety_checks":["Approval is scoped to this organization."],"verification_steps":["Read back the target state after execution."],"generated_at":"2026-04-23T12:29:00Z"}}}`,
},
{
name: "question",
event: mustStreamEvent(t, "question", chat.QuestionData{
SessionID: "session-1",
QuestionID: "question-1",
Questions: []chat.Question{
{
ID: "target",
Type: "select",
Question: "Which node should I inspect?",
Header: "Target",
Options: []chat.QuestionOption{
{Label: "Node A", Value: "node-a", Description: "Primary compute node"},
{Label: "Node B", Value: "node-b", Description: "Replica node"},
},
},
},
}),
want: `{"type":"question","data":{"session_id":"session-1","question_id":"question-1","questions":[{"id":"target","type":"select","question":"Which node should I inspect?","header":"Target","options":[{"label":"Node A","value":"node-a","description":"Primary compute node"},{"label":"Node B","value":"node-b","description":"Replica node"}]}]}}`,
},
{
// A steering client settles its queued follow-up from this event;
// with the browser's generated chat types gone, this snapshot is
// the only pin on its wire shape.
name: "steer_applied",
event: mustStreamEvent(t, "steer_applied", chat.SteerAppliedData{
SessionID: "session-1",
MessageID: "message-7",
ClientMessageID: "client-3",
Prompt: "also check the backups",
Turn: 2,
}),
want: `{"type":"steer_applied","data":{"session_id":"session-1","message_id":"message-7","client_message_id":"client-3","prompt":"also check the backups","turn":2}}`,
},
{
name: "done",
event: mustStreamEvent(t, "done", chat.DoneData{
@@ -19262,6 +19228,9 @@ func TestContract_AgentCapabilitiesManifestVersionIsPinned(t *testing.T) {
if !strings.Contains(src, `Version: "v1"`) {
t.Error("agent capabilities manifest must pin Version to v1; bumping is a breaking-change decision and should not happen silently")
}
if strings.Contains(src, `InteractiveQuestions: true`) {
t.Error("Pulse Assistant surface must not declare interactive questions: no Pulse client can answer pulse_question since the web chat was retired")
}
for _, required := range []string{
`SurfaceContract: SurfaceContract{`,
`ID: "pulse_intelligence_core"`,
@@ -19269,7 +19238,6 @@ func TestContract_AgentCapabilitiesManifestVersionIsPinned(t *testing.T) {
`ID: "pulse_patrol"`,
`ID: SurfaceIDPulseAssistant`,
`ID: SurfaceIDPulseMCP`,
`InteractiveQuestions: true`,
`CapabilityMetadata: true`,
`SurfaceToolContracts: []SurfaceToolContract{`,
`ToolNames: canonicalPulseMCPSurfaceToolNames(),`,
@@ -19366,65 +19334,20 @@ func TestContract_PulseIntelligenceSurfaceToolProjectionKeepsAssistantAndMCPDist
}
}
assistantProjectionSource, err := os.ReadFile(filepath.Join("..", "ai", "tools", "provider_projection.go"))
if err != nil {
t.Fatalf("read internal/ai/tools/provider_projection.go: %v", err)
}
assistantProjection := string(assistantProjectionSource)
for _, required := range []string{
`func (e *PulseToolExecutor) AssistantSurfaceToolContract(opts agentcapabilities.AssistantProviderToolOptions) agentcapabilities.SurfaceToolContract`,
`agentcapabilities.ProjectPulseAssistantSurfaceToolContract(`,
`agentcapabilities.CanonicalManifest().SurfaceContract`,
`e.AssistantProviderTools(opts)`,
// The authenticated surface-tools route and its chat and tools wrappers
// served only the retired web chat window; they must not come back.
for _, retired := range []struct{ path, symbol string }{
{filepath.Join("..", "ai", "tools", "provider_projection.go"), `func (e *PulseToolExecutor) AssistantSurfaceToolContract(`},
{filepath.Join("..", "ai", "chat", "service.go"), `func (s *Service) AssistantSurfaceToolContract(`},
{"ai_handler.go", `func (h *AIHandler) HandleAssistantSurfaceTools(`},
{"router_routes_ai_relay.go", `"/api/ai/assistant/surface-tools"`},
} {
if !strings.Contains(assistantProjection, required) {
t.Errorf("Assistant executor must expose native surface projection through shared core contract; missing %s", required)
source, err := os.ReadFile(retired.path)
if err != nil {
t.Fatalf("read %s: %v", retired.path, err)
}
}
chatServiceSource, err := os.ReadFile(filepath.Join("..", "ai", "chat", "service.go"))
if err != nil {
t.Fatalf("read internal/ai/chat/service.go: %v", err)
}
chatService := string(chatServiceSource)
for _, required := range []string{
`func (s *Service) AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract`,
`executor.SetExecuteAuthority(executeAuthorityFromContext(ctx))`,
`executor.AssistantSurfaceToolContract(agentcapabilities.AssistantProviderToolOptions{`,
`IncludeQuestionTool: !s.isAutonomousModeEnabled(),`,
} {
if !strings.Contains(chatService, required) {
t.Errorf("chat service must expose live native Assistant surface tools through the shared core contract; missing %s", required)
}
}
aiHandlerSource, err := os.ReadFile("ai_handler.go")
if err != nil {
t.Fatalf("read internal/api/ai_handler.go: %v", err)
}
aiHandler := string(aiHandlerSource)
for _, required := range []string{
`AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract`,
`func (h *AIHandler) HandleAssistantSurfaceTools(w http.ResponseWriter, r *http.Request)`,
`svc.AssistantSurfaceToolContract(ctx)`,
`Pulse Assistant is not running`,
} {
if !strings.Contains(aiHandler, required) {
t.Errorf("AI handler must expose authenticated native Assistant surface tool contract from runtime service; missing %s", required)
}
}
aiRelaySource, err := os.ReadFile("router_routes_ai_relay.go")
if err != nil {
t.Fatalf("read internal/api/router_routes_ai_relay.go: %v", err)
}
aiRelay := string(aiRelaySource)
for _, required := range []string{
`"/api/ai/assistant/surface-tools"`,
`RequireScope(config.ScopeAIChat, r.aiHandler.HandleAssistantSurfaceTools)`,
} {
if !strings.Contains(aiRelay, required) {
t.Errorf("Assistant surface tools endpoint must stay authenticated behind ai:chat route scope; missing %s", required)
if strings.Contains(string(source), retired.symbol) {
t.Errorf("%s must not reintroduce the retired web chat surface-tools path %s", retired.path, retired.symbol)
}
}
@@ -19446,15 +19369,16 @@ func TestContract_PulseIntelligenceSurfaceToolProjectionKeepsAssistantAndMCPDist
if assistant.ToolSource != agentcapabilities.SurfaceToolSourceAssistantRegistry {
t.Fatalf("Assistant tool source = %q", assistant.ToolSource)
}
// The canonical Assistant surface declares no interactive questions: no
// Pulse client can answer pulse_question since the web chat was retired.
if !reflect.DeepEqual(assistant.ToolNames, []string{
agentcapabilities.PulseQueryToolName,
agentcapabilities.PulseReadToolName,
agentcapabilities.PulseQuestionToolName,
}) {
t.Fatalf("Assistant surface tool names = %#v", assistant.ToolNames)
}
if !reflect.DeepEqual(assistant.NativeToolNames, []string{agentcapabilities.PulseQuestionToolName}) {
t.Fatalf("Assistant native tool names = %#v", assistant.NativeToolNames)
if len(assistant.NativeToolNames) != 0 {
t.Fatalf("Assistant native tool names = %#v, want none", assistant.NativeToolNames)
}
if len(assistant.CapabilityNames) != 0 {
t.Fatalf("Assistant surface must not duplicate MCP manifest capabilities: %#v", assistant.CapabilityNames)
@@ -21517,7 +21441,6 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
assistantQuestionToolSrc := string(assistantQuestionToolSource)
for _, fragment := range []string{
`const pulseQuestionToolName = agentcapabilities.PulseQuestionToolName`,
`agentcapabilities.ParsePulseQuestionToolInput(input)`,
} {
if !strings.Contains(assistantQuestionToolSrc, fragment) {
t.Errorf("Assistant question tool must use shared provider declaration; missing %s", fragment)
@@ -21654,10 +21577,12 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
t.Fatalf("read internal/ai/chat/agentic.go: %v", err)
}
agenticSrc := string(assistantChatAgentic)
if strings.Contains(agenticSrc, "IncludeQuestionTool: true") {
t.Error("agentic loop must not offer pulse_question: no Pulse client can answer it since the web chat was retired")
}
for _, fragment := range []string{
`providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{`,
`a.tools = a.executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{`,
`IncludeQuestionTool: true`,
`agentcapabilities.HasApprovalRequiredToolMarker(resultText)`,
`agentcapabilities.ParseApprovalRequiredToolMarkerData(resultText)`,
`inputWithApproval = agentcapabilities.WithApprovalArgument(inputWithApproval, approvalData.ApprovalID)`,
@@ -21754,6 +21679,9 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
t.Fatalf("read internal/ai/chat/service.go: %v", err)
}
chatServiceSrc := string(assistantChatService)
if strings.Contains(chatServiceSrc, "IncludeQuestionTool:") {
t.Error("chat service must not offer pulse_question: no Pulse client can answer it since the web chat was retired")
}
for _, fragment := range []string{
`manifest = s.executor.ListToolGovernance()`,
`agentcapabilities.ProviderToolNames(offeredTools)`,
@@ -21764,7 +21692,6 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
`fallbackAssistantToolGovernanceOfferedNames(manifest)`,
`agentcapabilities.ManifestSurfaceAffordances(`,
`providerTools := executor.AssistantProviderTools(agentcapabilities.AssistantProviderToolOptions{`,
`IncludeQuestionTool: includeQuestionTool`,
`agentcapabilities.BuildPulseAssistantOperatingInstructions()`,
`func (s *Service) executeAssistantRegistryToolDirect(ctx context.Context, toolName string, args map[string]interface{}, opts agentcapabilities.DirectToolExecutionOptions) (agentcapabilities.DirectToolExecutionOutcome, error)`,
`outcome, executionErr := s.executeAssistantRegistryToolDirect(ctx, agentcapabilities.PulseRunCommandToolName, args, agentcapabilities.DirectToolExecutionOptions{`,
-3
View File
@@ -730,15 +730,12 @@ var allRouteAllowlist = []string{
"/api/ai/incidents",
"/api/ai/incidents/",
"/api/ai/status",
"/api/ai/assistant/surface-tools",
"/api/ai/workflow-prompts/render",
"/api/ai/workflow-prompts/activity",
"/api/ai/chat",
"/api/ai/sessions",
"/api/ai/sessions/",
"/api/ai/approvals",
"/api/ai/approvals/",
"/api/ai/question/",
"/api/agent/ws",
"POST /api/agent/workflow-prompt-activity",
"POST /api/usage/workload-history",
+1 -67
View File
@@ -1075,7 +1075,7 @@ func (h *LicenseHandlers) RemoveTenantService(orgID string) {
// routeAISessions routes session-specific AI chat requests
func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
// Extract session ID from path: /api/ai/sessions/{id}[/messages|/abort|/summarize|/diff|/fork|/undo|/redo|/revert|/unrevert]
// Extract session ID from path: /api/ai/sessions/{id}[/messages|/abort]
path := strings.TrimPrefix(req.URL.Path, "/api/ai/sessions/")
parts := strings.SplitN(path, "/", 2)
sessionID := parts[0]
@@ -1098,46 +1098,6 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
return
}
r.aiHandler.HandleAbort(w, req, sessionID)
case "summarize":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleSummarize(w, req, sessionID)
case "diff":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleDiff(w, req, sessionID)
case "fork":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleFork(w, req, sessionID)
case "undo":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleUndoLastTurn(w, req, sessionID)
case "redo":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleRedoLastTurn(w, req, sessionID)
case "steer":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleSteerSession(w, req, sessionID)
case "revert":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleRevert(w, req, sessionID)
case "unrevert":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleUnrevert(w, req, sessionID)
default:
if !ensureScope(w, req, config.ScopeAIChat) {
return
@@ -1288,32 +1248,6 @@ func (r *Router) routeApprovals(w http.ResponseWriter, req *http.Request) {
}
}
// routeQuestions routes question-specific requests
func (r *Router) routeQuestions(w http.ResponseWriter, req *http.Request) {
// Extract question ID and action from path: /api/ai/question/{id}/answer
path := strings.TrimPrefix(req.URL.Path, "/api/ai/question/")
parts := strings.SplitN(path, "/", 2)
if parts[0] == "" {
http.Error(w, "Question ID required", http.StatusBadRequest)
return
}
questionID := parts[0]
// Check if there's an action
if len(parts) > 1 && parts[1] == "answer" {
if req.Method == http.MethodPost {
r.aiHandler.HandleAnswerQuestion(w, req, questionID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
return
}
http.Error(w, "Not found", http.StatusNotFound)
}
// handleAgentWebSocket handles WebSocket connections from agents for AI command execution
func (r *Router) handleAgentWebSocket(w http.ResponseWriter, req *http.Request) {
if r.agentExecServer == nil {
+16 -252
View File
@@ -38,6 +38,22 @@ func TestRouteAISessions_UnknownSubresource(t *testing.T) {
}
}
// The web chat window that used these session sub-resources is retired, and
// Pulse Mobile never called them; they fall to the unknown-subresource 404.
func TestRouteAISessions_RetiredWebChatSubresourcesAreNotFound(t *testing.T) {
for _, sub := range []string{"summarize", "fork", "undo", "redo", "steer", "diff", "revert", "unrevert"} {
router := &Router{}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/"+sub, nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("POST %s: expected status %d, got %d", sub, http.StatusNotFound, rec.Code)
}
}
}
func TestRouteAISessions_MethodNotAllowed(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1", nil)
@@ -140,222 +156,6 @@ func TestRouteAISessions_Abort(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Summarize(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("SummarizeSession", mock.Anything, "session-1").Return(map[string]interface{}{"summary": "ok"}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/summarize", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Diff(t *testing.T) {
mockSvc := &MockAIService{}
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/diff", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Fork(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("ForkSession", mock.Anything, "session-1").Return(&chat.Session{ID: "forked-1"}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/fork", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_UndoLastTurn(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("UndoLastTurn", mock.Anything, "session-1", chat.SessionTurnUndoOptions{}).Return(&chat.SessionTurnUndoResult{
Success: true,
SessionID: "session-1",
RestoredPrompt: "Inspect storage warnings",
CanRedo: true,
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/undo", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_UndoLastTurnRetryOptions(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("UndoLastTurn", mock.Anything, "session-1", chat.SessionTurnUndoOptions{
ExpectedPrompt: "Inspect storage warnings",
}).Return(&chat.SessionTurnUndoResult{
Success: true,
SessionID: "session-1",
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
body := strings.NewReader(`{"expected_prompt":"Inspect storage warnings"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/undo", body)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_SteerSession(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("SteerSession", mock.Anything, "session-1", chat.SessionSteerRequest{
Prompt: "also check pve2",
ClientMessageID: "client-row-1",
}).Return(&chat.SessionSteerResult{
Accepted: true,
SessionID: "session-1",
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
body := strings.NewReader(`{"prompt":"also check pve2","client_message_id":"client-row-1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/steer", body)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if !strings.Contains(rec.Body.String(), `"accepted":true`) {
t.Fatalf("expected accepted result, got %s", rec.Body.String())
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_SteerSessionRejectsNonPost(t *testing.T) {
mockSvc := &MockAIService{}
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/steer", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestRouteAISessions_RedoLastTurn(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("RedoLastTurn", mock.Anything, "session-1").Return(&chat.SessionTurnRedoResult{
Success: true,
SessionID: "session-1",
RestoredMessages: 2,
CanRedo: false,
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/redo", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Revert(t *testing.T) {
mockSvc := &MockAIService{}
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/revert", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Unrevert(t *testing.T) {
mockSvc := &MockAIService{}
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/unrevert", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteApprovals_MissingID(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals/", nil)
@@ -392,42 +192,6 @@ func TestRouteApprovals_MethodNotAllowed(t *testing.T) {
}
}
func TestRouteQuestions_MissingID(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodPost, "/api/ai/question/", nil)
rec := httptest.NewRecorder()
router.routeQuestions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestRouteQuestions_MethodNotAllowed(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodGet, "/api/ai/question/q1/answer", nil)
rec := httptest.NewRecorder()
router.routeQuestions(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestRouteQuestions_NotFound(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodPost, "/api/ai/question/q1/unknown", nil)
rec := httptest.NewRecorder()
router.routeQuestions(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
}
}
func TestHandleAgentWebSocket_Unavailable(t *testing.T) {
router := &Router{}
req := httptest.NewRequest(http.MethodGet, "/ws/agent", nil)
+2 -7
View File
@@ -252,11 +252,9 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.mux.HandleFunc("/api/ai/incidents", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetRecentIncidents)))
r.mux.HandleFunc("/api/ai/incidents/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetIncidentData)))
// AI chat endpoints
// SECURITY: Status endpoint is part of chat UX and should require ai:chat scope for token clients.
// AI chat endpoints (Pulse Mobile chat; the web chat window is retired)
// SECURITY: Status endpoint reports the chat service and should require ai:chat scope for token clients.
r.mux.HandleFunc("/api/ai/status", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiHandler.HandleStatus)))
r.mux.HandleFunc("/api/ai/assistant/surface-tools", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiHandler.HandleAssistantSurfaceTools)))
r.mux.HandleFunc("/api/ai/workflow-prompts/render", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiHandler.HandleRenderWorkflowPrompt)))
r.mux.HandleFunc("/api/ai/workflow-prompts/activity", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiHandler.HandleRecordWorkflowPromptActivity)))
r.mux.HandleFunc("/api/ai/chat", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteChatSend, r.aiHandler.HandleChat)))
r.mux.HandleFunc("/api/ai/sessions", RequireAuth(r.config, r.routeAISessionsCollection))
@@ -267,9 +265,6 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.mux.HandleFunc("/api/ai/approvals", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteApprovalsList, r.aiAutoFixEndpoints.HandleListApprovals)))
r.mux.HandleFunc("/api/ai/approvals/", RequireAuth(r.config, r.routeApprovals))
// AI question endpoints - require ai:chat scope for interactive AI features
r.mux.HandleFunc("/api/ai/question/", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.routeQuestions)))
// Provide extension endpoints to the approval handler for investigation fix gating
r.aiSettingsHandler.SetAIAutoFixEndpoints(r.aiAutoFixEndpoints)
}
+2 -5
View File
@@ -1988,8 +1988,6 @@ func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) {
}{
{method: http.MethodGet, path: "/api/ai/models", body: "", want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/status", body: "", want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: "", want: config.ScopeAIChat},
{method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`, want: config.ScopeAIChat},
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`, want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/agents", body: "", want: config.ScopeAIExecute},
{method: http.MethodGet, path: "/api/ai/approvals/approval-1", body: "", want: config.ScopeAIExecute},
@@ -1998,6 +1996,8 @@ func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) {
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reinvestigate", body: `{}`, want: config.ScopeAIExecute},
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reapprove", body: `{}`, want: config.ScopeAIExecute},
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: "", want: config.ScopeAIChat},
// Retired web chat sub-resources fall to the session router default,
// which still refuses tokens without ai:chat before answering 404.
{method: http.MethodPost, path: "/api/ai/sessions/session-1/summarize", body: `{}`, want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/sessions/session-1/diff", body: "", want: config.ScopeAIChat},
{method: http.MethodPost, path: "/api/ai/sessions/session-1/fork", body: `{}`, want: config.ScopeAIChat},
@@ -3504,13 +3504,10 @@ func TestAIChatEndpointsRequireAIChatScope(t *testing.T) {
body string
}{
{method: http.MethodGet, path: "/api/ai/models", body: ""},
{method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: ""},
{method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`},
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`},
{method: http.MethodPost, path: "/api/ai/chat", body: `{}`},
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: ""},
{method: http.MethodGet, path: "/api/ai/question/q-1", body: ""},
{method: http.MethodGet, path: "/api/ai/knowledge", body: ""},
{method: http.MethodGet, path: "/api/ai/knowledge/export", body: ""},
}
@@ -46,7 +46,7 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase):
normalized_content,
)
self.assertIn(
"Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools and interactive questions.",
"Pulse Assistant**: The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions. Affordances: tools.",
normalized_content,
)
self.assertIn(