Align Assistant tool context and progress visibility

This commit is contained in:
rcourtman
2026-06-06 04:02:07 +01:00
parent 409cc9b836
commit f70c0214db
5 changed files with 74 additions and 21 deletions
@@ -299,6 +299,12 @@ runtime cost control, and shared AI transport surfaces.
the selected model owns investigation and action choice inside Pulse policy.
Direct text turns such as exact-reply diagnostics must withhold tools and
use a scoped system prompt that does not advertise unavailable tools.
Short alert and finding prompts are not direct-text turns: prompts such as
"Alerts count", "active alerts", or "findings count" must keep the full
governed manifest, including `pulse_alerts`, so the selected model can
choose the canonical Pulse alert/finding tool instead of receiving a
no-tools system prompt and asking the operator to paste context Pulse
already owns.
Inventory, count, overview, status, list, and breakdown prompts that only
need canonical Pulse resource state must expose the canonical
query/clarification path instead of shell/read/control tools, so prompts
@@ -364,7 +370,14 @@ runtime cost control, and shared AI transport surfaces.
stream events must render inline as compact rows in the assistant turn,
transition to `running` or `waiting` through `tool_progress`, and resolve in
place on `tool_end` instead of disappearing until completion or relying only
on the drawer-level status bar.
on the drawer-level status bar. The referenced OpenCode source at fetched
`origin/dev` commit `ba57718b0516c7a8670d1e820b1a24146a8b8262` emits an ACP
`tool_call` when a tool part is known and then sends `tool_call_update`
patches for pending, running, completed, and failed states through
`packages/opencode/src/acp/event.ts` and
`packages/opencode/src/acp/tool.ts`; Pulse adapts that by keeping the live
pending row's current progress text visible inside the transcript at drawer
width, not only in the footer/status strip.
Assistant session listing is a history-browsing path, not a send-path lock.
The session store must not hold its shared mutex while scanning and parsing
every persisted session file for `/api/ai/sessions`; writes must remain
@@ -168,27 +168,31 @@ export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
});
return (
<div class="my-0.5 font-mono text-[11px] flex items-center gap-1.5 px-2 py-1 rounded bg-surface-alt border border-border">
<Show
when={status() === 'waiting'}
fallback={<LoaderCircleIcon class={activityIconClass()} aria-label={statusLabel()} />}
>
<ClockIcon class={activityIconClass()} aria-label={statusLabel()} />
</Show>
<div class="my-0.5 font-mono text-[11px] rounded border border-border bg-surface-alt px-2 py-1">
<div class="flex min-w-0 items-center gap-1.5">
<Show
when={status() === 'waiting'}
fallback={<LoaderCircleIcon class={activityIconClass()} aria-label={statusLabel()} />}
>
<ClockIcon class={activityIconClass()} aria-label={statusLabel()} />
</Show>
<span class="text-muted uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
{toolLabel()}
</span>
<span class="text-base-content truncate flex-1">{inputSummary()}</span>
<Show when={progressText()}>
<span class="hidden sm:inline-block max-w-[180px] truncate text-[10px] text-muted">
{progressText()}
<span class="text-muted uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
{toolLabel()}
</span>
</Show>
<span class="shrink-0 text-[10px] text-muted">{statusLabel()}</span>
<Show when={elapsedLabel()}>
<span class="shrink-0 text-[10px] text-muted">{elapsedLabel()}</span>
<span class="min-w-0 flex-1 truncate text-base-content">{inputSummary()}</span>
<span class="shrink-0 text-[10px] text-muted">{statusLabel()}</span>
<Show when={elapsedLabel()}>
<span class="shrink-0 text-[10px] text-muted">{elapsedLabel()}</span>
</Show>
</div>
<Show when={progressText()}>
<div class="mt-0.5 min-w-0 pl-[calc(0.75rem+56px)] text-[10px] leading-snug text-muted">
<span class="block truncate" title={progressText()}>
{progressText()}
</span>
</div>
</Show>
</div>
);
@@ -312,7 +312,10 @@ describe('PendingToolBlock', () => {
));
expect(screen.getByText('running')).toBeInTheDocument();
expect(screen.getByText('Running command.')).toBeInTheDocument();
const progress = screen.getByText('Running command.');
expect(progress).toBeInTheDocument();
expect(progress).toHaveAttribute('title', 'Running command.');
expect(progress.className).not.toContain('hidden');
});
it('renders waiting status without a spinner', () => {
+4
View File
@@ -4338,6 +4338,10 @@ func assistantPromptNeedsFullToolManifest(normalized string) bool {
" snapshot ",
" approve ",
" deny ",
" alert ",
" alerts ",
" finding ",
" findings ",
" cpu ",
" memory ",
" disk ",
+29
View File
@@ -223,6 +223,35 @@ func TestToolsForAssistantTurn_ActionAndDiagnosticsKeepFullManifest(t *testing.T
}
}
func TestToolsForAssistantTurn_AlertPromptsKeepFullManifest(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},
AgentServer: fakeAgentServer{},
ReadState: &fakeCanonicalReadState{},
ControlLevel: tools.ControlLevelControlled,
})
svc := &Service{executor: exec}
for _, prompt := range []string{
"Alerts count",
"active alerts",
"findings count",
} {
toolsList := svc.toolsForAssistantTurn(prompt, false, false, false)
set := toolNameSet(toolsList)
if !set["pulse_alerts"] || !set["pulse_query"] {
t.Fatalf("expected full manifest for %q, got %#v", prompt, set)
}
promptText := svc.buildSystemPromptForOfferedTools(toolsList)
if strings.Contains(promptText, "No Pulse tools are offered for this turn") {
t.Fatalf("expected alert prompt to avoid text-only tool boundary, got %q", promptText)
}
if !strings.Contains(promptText, "pulse_alerts: mode=mixed") {
t.Fatalf("expected alert prompt system prompt to advertise pulse_alerts, got %q", promptText)
}
}
}
func TestToolsForAssistantTurn_ModelHandoffKeepsFullManifest(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{
StateProvider: fakeStateProvider{},