mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{`,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user