mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): restore observer-feed regressions from #1381 and classify 4 new session/update types (#1412)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
bdeab23b59
commit
fec7684366
@@ -35,6 +35,7 @@ export default defineConfig({
|
||||
"**/active-turn-resilience.spec.ts",
|
||||
"**/profile-active-turn.spec.ts",
|
||||
"**/config-bridge-screenshots.spec.ts",
|
||||
"**/observer-feed-screenshots.spec.ts",
|
||||
"**/file-attachment.spec.ts",
|
||||
"**/image-attachment-gallery.spec.ts",
|
||||
"**/video-attachment.spec.ts",
|
||||
|
||||
@@ -132,7 +132,18 @@ const overrides = new Map([
|
||||
["src/shared/ui/markdown.tsx", 2119],
|
||||
["src/shared/ui/VideoPlayer.tsx", 2199],
|
||||
["src/shared/ui/sidebar.tsx", 1042],
|
||||
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
|
||||
// permission-outcome (fix #1381 regression): pendingPermissions state map,
|
||||
// describePermissionOutcome helper, jsonRpcId key helper (handles both
|
||||
// string and finite-number JSON-RPC ids per spec), and the acp_write
|
||||
// response correlation branch are all tightly coupled to the existing
|
||||
// request handler. Load-bearing logic growth, not generic debt. Queued to
|
||||
// split into a dedicated permission module in the next transcript refactor.
|
||||
// +123: observer parity — 4 new named session/update classifier cases
|
||||
// (current_mode_update, usage_update, available_commands_update,
|
||||
// config_option_update) + replaceLifecycleItem helper for usage coalescing +
|
||||
// system-prompt ordering fix (turnId: null for per-channel items).
|
||||
// Load-bearing feature growth; queued to split in next transcript refactor.
|
||||
["src/features/agents/ui/agentSessionTranscript.ts", 1167],
|
||||
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
|
||||
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
|
||||
// fallback. Queued to split into dedicated discovery modules.
|
||||
|
||||
@@ -424,6 +424,25 @@ export function useManagedAgentObserverBridge(
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E-only: inject synthetic observer events directly into the store, bypassing
|
||||
* the relay-security knownAgentPubkeys filter. Exercises the real
|
||||
* appendAgentEvent → processTranscriptEvent ingestion path so screenshot specs
|
||||
* prove the production render, not a stub.
|
||||
*
|
||||
* Never call this from production code — it is intentionally not re-exported
|
||||
* from the public agent feature barrel.
|
||||
*/
|
||||
export function injectObserverEventsForE2E(
|
||||
agentPubkey: string,
|
||||
events: ObserverEvent[],
|
||||
) {
|
||||
for (const event of events) {
|
||||
appendAgentEvent(agentPubkey, event);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
export function resetAgentObserverStore() {
|
||||
generation += 1;
|
||||
const unsubscribe = unsubscribeRelay;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
|
||||
import { CheckCheck, Radio } from "lucide-react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Toggle } from "@/shared/ui/toggle";
|
||||
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
|
||||
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
||||
import {
|
||||
ActivityRow,
|
||||
@@ -207,6 +206,7 @@ function TranscriptTurnSegmentView({
|
||||
context={segment.context}
|
||||
profiles={profiles}
|
||||
setup={segment.setup}
|
||||
systemPrompt={segment.systemPrompt}
|
||||
user={segment.user}
|
||||
/>
|
||||
);
|
||||
@@ -355,11 +355,13 @@ function TurnPromptBlock({
|
||||
context,
|
||||
profiles,
|
||||
setup,
|
||||
systemPrompt,
|
||||
user,
|
||||
}: {
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
profiles?: UserProfileLookup;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
}) {
|
||||
return (
|
||||
@@ -377,6 +379,7 @@ function TurnPromptBlock({
|
||||
item={user}
|
||||
profiles={profiles}
|
||||
setup={setup}
|
||||
systemPrompt={systemPrompt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -387,111 +390,72 @@ function PromptUserMessage({
|
||||
item,
|
||||
profiles,
|
||||
setup = [],
|
||||
systemPrompt = null,
|
||||
}: {
|
||||
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
systemPrompt?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
}) {
|
||||
const [contextOpen, setContextOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserMessageBubble
|
||||
bubbleClassName="p-2.5"
|
||||
footer={
|
||||
<TurnSetupFooter
|
||||
context={context}
|
||||
contextOpen={contextOpen}
|
||||
items={setup}
|
||||
messageLink={getTranscriptMessageLink(item)}
|
||||
onContextOpenChange={setContextOpen}
|
||||
timestamp={item.timestamp}
|
||||
/>
|
||||
}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<PromptContextDialog
|
||||
context={context}
|
||||
onOpenChange={setContextOpen}
|
||||
open={contextOpen}
|
||||
setup={setup}
|
||||
/>
|
||||
{systemPrompt && systemPrompt.sections.length > 0 ? (
|
||||
<PromptContextInline context={systemPrompt} />
|
||||
) : null}
|
||||
{context && context.sections.length > 0 ? (
|
||||
<PromptContextInline context={context} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextSections({
|
||||
className,
|
||||
sections,
|
||||
function PromptContextInline({
|
||||
context,
|
||||
}: {
|
||||
className?: string;
|
||||
sections: PromptSection[];
|
||||
context: Extract<TranscriptItem, { type: "metadata" }>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-3", className)}
|
||||
data-testid="transcript-prompt-context-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<PromptContextSectionAccordion
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextSectionAccordion({
|
||||
section,
|
||||
}: {
|
||||
section: PromptSection;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const body = section.body.trim();
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-2xl bg-muted/40">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
type="button"
|
||||
<>
|
||||
<div
|
||||
className="mt-1 space-y-2 pl-2"
|
||||
data-testid="transcript-prompt-context-inline"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold text-foreground",
|
||||
!open && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-xs leading-5 text-foreground/70",
|
||||
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{body.length > 0 ? (
|
||||
body
|
||||
) : (
|
||||
<span className="italic text-foreground/50">No metadata.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground/70">
|
||||
{context.title}
|
||||
</p>
|
||||
<button
|
||||
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
data-testid="transcript-prompt-context-expand"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
View full
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
<PromptContextSections sections={context.sections} />
|
||||
</div>
|
||||
<PromptContextDialog
|
||||
context={context}
|
||||
onOpenChange={setDialogOpen}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -499,33 +463,22 @@ function PromptContextDialog({
|
||||
context,
|
||||
onOpenChange,
|
||||
open,
|
||||
setup,
|
||||
}: {
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
context: Extract<TranscriptItem, { type: "metadata" }>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
if (!open || !context || context.sections.length === 0) {
|
||||
if (!open || context.sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setupText = formatPromptSetupSummary(setup);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
|
||||
<DialogTitle>Prompt context</DialogTitle>
|
||||
{setupText ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<DialogDescription>{setupText}</DialogDescription>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogTitle>{context.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
|
||||
<PromptContextSections sections={context.sections} />
|
||||
</div>
|
||||
@@ -535,28 +488,14 @@ function PromptContextDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function formatPromptSetupSummary(
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
||||
) {
|
||||
const label = formatTurnSetupLabel(items);
|
||||
const detail = turnSetupDetail(items);
|
||||
return [label, detail].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function TurnSetupFooter({
|
||||
context = null,
|
||||
contextOpen = false,
|
||||
items,
|
||||
messageLink = null,
|
||||
onContextOpenChange,
|
||||
showTimestamp = true,
|
||||
timestamp,
|
||||
}: {
|
||||
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
contextOpen?: boolean;
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
messageLink?: { channelId: string; messageId: string } | null;
|
||||
onContextOpenChange?: (open: boolean) => void;
|
||||
showTimestamp?: boolean;
|
||||
timestamp: string;
|
||||
}) {
|
||||
@@ -564,41 +503,22 @@ function TurnSetupFooter({
|
||||
const detail = turnSetupDetail(items);
|
||||
const tooltipText = [label, detail].filter(Boolean).join(" · ");
|
||||
const showSetup = items.length > 0;
|
||||
const showContext = context != null && context.sections.length > 0;
|
||||
|
||||
if (!showSetup && !showContext) {
|
||||
if (!showSetup) {
|
||||
return showTimestamp ? (
|
||||
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
const contextToggle = showContext ? (
|
||||
<Toggle
|
||||
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
|
||||
data-testid="transcript-prompt-context-toggle"
|
||||
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
|
||||
onPressedChange={onContextOpenChange}
|
||||
pressed={contextOpen}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<CheckCheck aria-hidden="true" />
|
||||
</Toggle>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-muted-foreground/80"
|
||||
data-testid="transcript-turn-setup"
|
||||
>
|
||||
{showContext && showSetup ? contextToggle : null}
|
||||
{!showContext && showSetup ? (
|
||||
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{showContext && !showSetup ? contextToggle : null}
|
||||
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</span>
|
||||
{showTimestamp ? (
|
||||
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { PromptSection } from "./agentSessionTypes";
|
||||
|
||||
export function PromptSectionList({
|
||||
className,
|
||||
sections,
|
||||
}: {
|
||||
className?: string;
|
||||
sections: PromptSection[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-2", className)}
|
||||
data-testid="transcript-prompt-context-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<PromptSectionAccordion
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptSectionAccordion({
|
||||
section,
|
||||
}: {
|
||||
section: PromptSection;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const body = section.body.trim();
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-2xl bg-muted/40">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold text-foreground",
|
||||
!open && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-xs leading-5 text-foreground/70",
|
||||
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{body.length > 0 ? (
|
||||
body
|
||||
) : (
|
||||
<span className="italic text-foreground/50">No metadata.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,42 @@
|
||||
import { AlertCircle, ShieldCheck } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
|
||||
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
/**
|
||||
* Split the permission item's text into the request description lines and the
|
||||
* options line. The text is newline-joined by describePermissionRequest:
|
||||
* [request title?] [toolCallId?] ["Options: ..."]
|
||||
* We surface the options line separately so the render can style it distinctly.
|
||||
*/
|
||||
function splitPermissionText(text: string): {
|
||||
requestLines: string;
|
||||
optionsLine: string | null;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const optionsIdx = lines.findIndex((l) => l.startsWith("Options: "));
|
||||
if (optionsIdx === -1) {
|
||||
return { requestLines: text, optionsLine: null };
|
||||
}
|
||||
return {
|
||||
requestLines: lines.slice(0, optionsIdx).join("\n"),
|
||||
optionsLine: lines[optionsIdx],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the visual tone and icon for a resolved permission outcome string.
|
||||
* Outcome strings come from describePermissionOutcome:
|
||||
* "Approved (...)" | "Denied (...)" | "Cancelled"
|
||||
*/
|
||||
function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" {
|
||||
if (outcome.startsWith("Approved")) return "approve";
|
||||
if (outcome.startsWith("Denied")) return "deny";
|
||||
return "cancel";
|
||||
}
|
||||
|
||||
export function LifecycleActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
@@ -20,16 +52,51 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
|
||||
const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp);
|
||||
|
||||
if (isPermission) {
|
||||
const { requestLines, optionsLine } = splitPermissionText(props.item.text);
|
||||
const outcome = props.item.outcome;
|
||||
const tone = outcome ? permissionOutcomeTone(outcome) : null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
|
||||
data-testid="transcript-permission-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
|
||||
<span className="font-medium">{props.item.title}</span>
|
||||
{props.item.text ? (
|
||||
<span className="opacity-80"> · {props.item.text}</span>
|
||||
{/* Row 1: request */}
|
||||
<div>
|
||||
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
|
||||
<span className="font-medium">{props.item.title}</span>
|
||||
{requestLines ? (
|
||||
<span className="opacity-80"> · {requestLines}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Row 2: options (muted sub-line) */}
|
||||
{optionsLine ? (
|
||||
<div className="mt-0.5 pl-5 opacity-60">{optionsLine}</div>
|
||||
) : null}
|
||||
{/* Row 3: decision — only when outcome is resolved */}
|
||||
{outcome && tone ? (
|
||||
<>
|
||||
<div className="my-1 border-t border-amber-500/20" />
|
||||
<div
|
||||
className={
|
||||
tone === "approve"
|
||||
? "flex items-center gap-1 font-medium text-green-600 dark:text-green-400"
|
||||
: tone === "deny"
|
||||
? "flex items-center gap-1 font-medium text-destructive"
|
||||
: "flex items-center gap-1 font-medium text-muted-foreground"
|
||||
}
|
||||
data-testid="transcript-permission-outcome"
|
||||
>
|
||||
{tone === "approve" ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
) : tone === "deny" ? (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
)}
|
||||
{outcome}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RawRailActivity } from "./RawRailActivity.tsx";
|
||||
|
||||
const baseProps = {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Test Agent",
|
||||
agentPubkey: "pubkey123",
|
||||
};
|
||||
const baseTimestamp = "2026-06-14T19:00:00.000Z";
|
||||
const baseIdentity = {
|
||||
agentPubkey: "pubkey123",
|
||||
sessionId: "session-001",
|
||||
turnId: null,
|
||||
};
|
||||
|
||||
test("RawRailActivity render: raw_json_rpc keeps <pre> safety net", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RawRailActivity, {
|
||||
...baseProps,
|
||||
item: {
|
||||
...baseIdentity,
|
||||
id: "meta:raw",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Raw ACP payload",
|
||||
sections: [{ title: "body", body: "{}" }],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "raw_json_rpc",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.ok(html.includes("<pre"), "raw_json_rpc should render <pre>");
|
||||
assert.ok(
|
||||
!html.includes("transcript-prompt-context-sections"),
|
||||
"raw_json_rpc should not render polished accordion",
|
||||
);
|
||||
});
|
||||
|
||||
test("RawRailActivity render: system prompt (no acpSource) uses polished accordion", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RawRailActivity, {
|
||||
...baseProps,
|
||||
item: {
|
||||
...baseIdentity,
|
||||
id: "meta:sys",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "System prompt",
|
||||
sections: [{ title: "Instructions", body: "You are an agent." }],
|
||||
timestamp: baseTimestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
html.includes("transcript-prompt-context-sections"),
|
||||
"system prompt should render polished accordion",
|
||||
);
|
||||
assert.ok(!html.includes("<pre"), "system prompt should not render <pre>");
|
||||
});
|
||||
|
||||
test("RawRailActivity render: steer-turn prompt context uses polished accordion", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RawRailActivity, {
|
||||
...baseProps,
|
||||
item: {
|
||||
...baseIdentity,
|
||||
id: "meta:ctx",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Prompt context",
|
||||
sections: [{ title: "Thread history", body: "..." }],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "session/prompt:context",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
html.includes("transcript-prompt-context-sections"),
|
||||
"steer-turn prompt context should render polished accordion",
|
||||
);
|
||||
assert.ok(
|
||||
!html.includes("<pre"),
|
||||
"steer-turn prompt context should not render <pre>",
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import { PromptSectionList } from "../PromptSectionAccordion";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function RawRailActivity(props: ActivityRenderClassItemProps) {
|
||||
@@ -17,33 +18,39 @@ export function RawRailActivity(props: ActivityRenderClassItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectionCount = props.item.sections.length;
|
||||
const sectionSuffix = `${sectionCount} section${sectionCount === 1 ? "" : "s"}`;
|
||||
const isRawPayload = props.item.acpSource === "raw_json_rpc";
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-metadata-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel
|
||||
object={`${props.item.sections.length} raw section${
|
||||
props.item.sections.length === 1 ? "" : "s"
|
||||
}`}
|
||||
object={sectionSuffix}
|
||||
openToneScope="tool"
|
||||
verb="Captured"
|
||||
verb={props.item.title}
|
||||
/>
|
||||
<ActivityRowContent className="flex flex-col gap-3 py-2">
|
||||
{props.item.sections.map((section) => (
|
||||
<details
|
||||
className="group/section"
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground/60 group-open/section:text-foreground">
|
||||
<span className="truncate">{section.title}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{section.body.trim() || "No metadata."}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
{isRawPayload ? (
|
||||
props.item.sections.map((section) => (
|
||||
<details
|
||||
className="group/section"
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground/60 group-open/section:text-foreground">
|
||||
<span className="truncate">{section.title}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{section.body.trim() || "No metadata."}
|
||||
</pre>
|
||||
</details>
|
||||
))
|
||||
) : (
|
||||
<PromptSectionList sections={props.item.sections} />
|
||||
)}
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildTranscript } from "./agentSessionTranscript.ts";
|
||||
import {
|
||||
buildTranscriptDisplayBlocks,
|
||||
flattenDisplayBlocks,
|
||||
} from "./agentSessionTranscriptGrouping.ts";
|
||||
import { formatToolTitle } from "./agentSessionToolCatalog.ts";
|
||||
|
||||
const baseEvent = {
|
||||
@@ -639,3 +643,539 @@ test("buildTranscript separates repeated lifecycle text", () => {
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.text, "recovered: first\nrecovered: second");
|
||||
});
|
||||
|
||||
// --- permission outcome (Fix #3) ---
|
||||
|
||||
function makePermissionRequest(seq, requestId, turnId = "turn-1") {
|
||||
return {
|
||||
seq,
|
||||
timestamp: "2026-06-30T10:00:00.000Z",
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: requestId,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
title: "Confirm push",
|
||||
toolCallId: "tool-1",
|
||||
options: [
|
||||
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
||||
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePermissionResponse(seq, requestId, outcome, optionId = null) {
|
||||
const resultOutcome =
|
||||
outcome === "selected" ? { outcome: "selected", optionId } : { outcome };
|
||||
return {
|
||||
seq,
|
||||
timestamp: "2026-06-30T10:00:01.000Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: requestId,
|
||||
result: { outcome: resultOutcome },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("buildTranscript appends Approved outcome when allow_once is selected", () => {
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, "req-1"),
|
||||
makePermissionResponse(2, "req-1", "selected", "allow_once"),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "permission");
|
||||
assert.equal(item.outcome, "Approved (allow_once)");
|
||||
assert.doesNotMatch(item.text ?? "", /Approved/);
|
||||
});
|
||||
|
||||
test("buildTranscript appends Denied outcome when reject_once is selected", () => {
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, "req-2"),
|
||||
makePermissionResponse(2, "req-2", "selected", "reject_once"),
|
||||
]);
|
||||
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.outcome, "Denied (reject_once)");
|
||||
assert.doesNotMatch(item.text ?? "", /Denied/);
|
||||
});
|
||||
|
||||
test("buildTranscript appends Cancelled outcome on cancelled response", () => {
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, "req-3"),
|
||||
makePermissionResponse(2, "req-3", "cancelled"),
|
||||
]);
|
||||
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.outcome, "Cancelled");
|
||||
assert.doesNotMatch(item.text ?? "", /Cancelled/);
|
||||
});
|
||||
|
||||
test("buildTranscript no-ops on a permission response with an unmatched id", () => {
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, "req-4"),
|
||||
makePermissionResponse(2, "req-WRONG", "selected", "allow_once"),
|
||||
]);
|
||||
|
||||
// The permission item exists but has no outcome appended — the mismatched
|
||||
// response id must not crash or attach to the wrong item.
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "permission");
|
||||
assert.equal(item.outcome, undefined);
|
||||
assert.doesNotMatch(item.text ?? "", /Approved/);
|
||||
assert.doesNotMatch(item.text ?? "", /Denied/);
|
||||
});
|
||||
|
||||
test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (selected allow_once)", () => {
|
||||
// JSON-RPC 2.0 allows numeric ids; the ACP runtime preserves them as
|
||||
// serde_json::Value. asString() drops numbers, so this exercises the
|
||||
// jsonRpcId() helper path that handles finite-number ids.
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, 42),
|
||||
makePermissionResponse(2, 42, "selected", "allow_once"),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "permission");
|
||||
assert.equal(item.outcome, "Approved (allow_once)");
|
||||
assert.doesNotMatch(item.text ?? "", /Approved/);
|
||||
});
|
||||
|
||||
test("buildTranscript appends Cancelled outcome for a numeric JSON-RPC id (cancelled)", () => {
|
||||
const transcript = buildTranscript([
|
||||
makePermissionRequest(1, 99),
|
||||
makePermissionResponse(2, 99, "cancelled"),
|
||||
]);
|
||||
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.outcome, "Cancelled");
|
||||
assert.doesNotMatch(item.text ?? "", /Cancelled/);
|
||||
});
|
||||
|
||||
test('buildTranscript does not collide between numeric id 1 and string id "1"', () => {
|
||||
// JSON.stringify produces "1" for number 1 and "\"1\"" for string "1",
|
||||
// so requests with these two different id types must NOT cross-attach.
|
||||
const transcriptNumeric = buildTranscript([
|
||||
makePermissionRequest(1, 1),
|
||||
makePermissionResponse(2, 1, "selected", "allow_once"),
|
||||
]);
|
||||
const transcriptString = buildTranscript([
|
||||
makePermissionRequest(1, "1"),
|
||||
makePermissionResponse(2, "1", "selected", "reject_once"),
|
||||
]);
|
||||
|
||||
assert.equal(transcriptNumeric[0].outcome, "Approved (allow_once)");
|
||||
assert.equal(transcriptString[0].outcome, "Denied (reject_once)");
|
||||
});
|
||||
|
||||
// ─── observer parity: new session/update classifier cases ────────────────────
|
||||
|
||||
test("buildTranscript renders current_mode_update as a lifecycle status line", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "status");
|
||||
assert.equal(item.title, "Mode");
|
||||
assert.equal(item.text, "plan");
|
||||
assert.equal(item.acpSource, "current_mode_update");
|
||||
});
|
||||
|
||||
test("buildTranscript suppresses current_mode_update when currentModeId is missing", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, { sessionUpdate: "current_mode_update" }),
|
||||
]);
|
||||
assert.equal(transcript.length, 0);
|
||||
});
|
||||
|
||||
test("buildTranscript renders usage_update as a lifecycle status line with tokens", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 1500,
|
||||
size: 8192,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "status");
|
||||
assert.equal(item.title, "Usage");
|
||||
assert.equal(item.text, "Tokens: 1500/8192");
|
||||
assert.equal(item.acpSource, "usage_update");
|
||||
});
|
||||
|
||||
test("buildTranscript renders usage_update with cost when present", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 800,
|
||||
size: 4096,
|
||||
cost: { amount: 0.0025, currency: "USD" },
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
assert.equal(transcript[0].text, "Tokens: 800/4096 ($0.0025 USD)");
|
||||
});
|
||||
|
||||
test("buildTranscript coalesces usage_update to latest-per-turn (replace, not append)", () => {
|
||||
// Three usage frames in the same turn must produce exactly ONE lifecycle item
|
||||
// showing the LAST value — not an accumulation of all three.
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100, size: 8192 }),
|
||||
acpToolUpdate(2, { sessionUpdate: "usage_update", used: 300, size: 8192 }),
|
||||
acpToolUpdate(3, { sessionUpdate: "usage_update", used: 600, size: 8192 }),
|
||||
]);
|
||||
|
||||
const usageItems = transcript.filter((i) => i.acpSource === "usage_update");
|
||||
assert.equal(usageItems.length, 1, "must coalesce to one item");
|
||||
assert.equal(usageItems[0].text, "Tokens: 600/8192");
|
||||
});
|
||||
|
||||
test("buildTranscript suppresses usage_update when used or size is missing", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100 }), // size absent
|
||||
acpToolUpdate(2, { sessionUpdate: "usage_update", size: 8192 }), // used absent
|
||||
]);
|
||||
assert.equal(transcript.length, 0);
|
||||
});
|
||||
|
||||
test("buildTranscript renders available_commands_update as a lifecycle status line", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "create_plan", description: "Create a plan" },
|
||||
{ name: "research_codebase", description: "Research the codebase" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "status");
|
||||
assert.equal(item.title, "Commands");
|
||||
assert.equal(item.text, "Commands available: 2");
|
||||
assert.equal(item.acpSource, "available_commands_update");
|
||||
});
|
||||
|
||||
test("buildTranscript renders available_commands_update with zero commands", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
assert.equal(transcript[0].text, "Commands available: 0");
|
||||
});
|
||||
|
||||
test("buildTranscript renders config_option_update as a lifecycle status line", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{ id: "model", name: "Model", type: "select", currentValue: "gpt-4o" },
|
||||
{ id: "mode", name: "Mode", type: "select", currentValue: "auto" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
const item = transcript[0];
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.renderClass, "status");
|
||||
assert.equal(item.title, "Config");
|
||||
assert.equal(item.text, "Model = gpt-4o, Mode = auto");
|
||||
assert.equal(item.acpSource, "config_option_update");
|
||||
});
|
||||
|
||||
test("buildTranscript suppresses config_option_update when configOptions is empty", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [],
|
||||
}),
|
||||
]);
|
||||
assert.equal(transcript.length, 0);
|
||||
});
|
||||
|
||||
test("buildTranscript does not render keepalive (stays in else-dropped bucket)", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, { sessionUpdate: "keepalive" }),
|
||||
]);
|
||||
assert.equal(transcript.length, 0);
|
||||
});
|
||||
|
||||
test("buildTranscript does not render unknown session/update types (firehose safety net)", () => {
|
||||
const transcript = buildTranscript([
|
||||
acpToolUpdate(1, { sessionUpdate: "some_future_type", value: 42 }),
|
||||
]);
|
||||
assert.equal(transcript.length, 0);
|
||||
});
|
||||
|
||||
// --- system-prompt ordering ---
|
||||
|
||||
test("observer feed renders system-prompt before prompt-context in display order (first turn, realistic pool.rs sequence)", () => {
|
||||
// Reproduces the real ordering bug: pool.rs emits turn_started BEFORE session/new,
|
||||
// so turn_started creates the turn bucket first. Without the injection mechanism,
|
||||
// displayOrder becomes [turn(turn-1), single(system-prompt)] and System prompt
|
||||
// renders after the entire turn block — after Prompt context.
|
||||
// The fix: system-prompt items (acpSource "session/new") are held and injected
|
||||
// into the prompt segment of the first turn that follows them in stream order.
|
||||
const events = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-01T10:00:00.000Z",
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: null,
|
||||
turnId: "turn-1",
|
||||
payload: { source: "channel", triggeringEventIds: [] },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-01T10:00:00.100Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: null,
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver Agent.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-07-01T10:00:00.200Z",
|
||||
kind: "session_resolved",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
payload: { sessionId: "sess-1", isNewSession: true },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: "2026-07-01T10:00:01.000Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
sessionId: "sess-1",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`,
|
||||
},
|
||||
{ type: "text", text: "[Thread context]\nPrior messages here." },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Route through the display layer — this is the layer that contained the bug.
|
||||
const rawItems = buildTranscript(events);
|
||||
const displayItems = flattenDisplayBlocks(
|
||||
buildTranscriptDisplayBlocks(rawItems),
|
||||
);
|
||||
const systemPromptIdx = displayItems.findIndex(
|
||||
(i) => i.title === "System prompt",
|
||||
);
|
||||
const promptContextIdx = displayItems.findIndex(
|
||||
(i) => i.title === "Prompt context",
|
||||
);
|
||||
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
|
||||
assert.ok(promptContextIdx !== -1, "expected a Prompt context item");
|
||||
assert.ok(
|
||||
systemPromptIdx < promptContextIdx,
|
||||
`expected System prompt (idx ${systemPromptIdx}) before Prompt context (idx ${promptContextIdx}) in display order`,
|
||||
);
|
||||
// Also verify the fix input: system-prompt item must have turnId=null so the
|
||||
// display grouper treats it as a standalone entry, not a turn-bucket item.
|
||||
const systemPromptRawIdx = rawItems.findIndex(
|
||||
(i) => i.title === "System prompt",
|
||||
);
|
||||
assert.equal(
|
||||
rawItems[systemPromptRawIdx].turnId ?? null,
|
||||
null,
|
||||
"system-prompt item must have turnId=null to avoid turn-bucket grouping",
|
||||
);
|
||||
});
|
||||
|
||||
test("observer feed renders system-prompt before prompt-context in display order (multi-turn)", () => {
|
||||
// On subsequent turns, session/new does not re-fire. The system-prompt item
|
||||
// injected into turn-1 must not re-appear in turn-2's prompt segment.
|
||||
const events = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-01T10:00:00.000Z",
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: null,
|
||||
turnId: "turn-1",
|
||||
payload: { source: "channel", triggeringEventIds: [] },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-01T10:00:00.100Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: null,
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt: "[Base]\nYou are helpful.\n\n[System]\nObserver.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-07-01T10:00:00.200Z",
|
||||
kind: "session_resolved",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
payload: { sessionId: "sess-1", isNewSession: true },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: "2026-07-01T10:00:01.000Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
sessionId: "sess-1",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: turn 1`,
|
||||
},
|
||||
{ type: "text", text: "[Thread context]\nEmpty." },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 5,
|
||||
timestamp: "2026-07-01T10:05:00.000Z",
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-2",
|
||||
payload: { source: "channel", triggeringEventIds: [] },
|
||||
},
|
||||
{
|
||||
seq: 6,
|
||||
timestamp: "2026-07-01T10:05:01.000Z",
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: "ch-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-2",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
sessionId: "sess-1",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[Buzz event: @mention]\nEvent ID: ${"c".repeat(64)}\nFrom: x (hex: ${"d".repeat(64)})\nContent: turn 2`,
|
||||
},
|
||||
{ type: "text", text: "[Thread context]\nOne prior message." },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rawItems = buildTranscript(events);
|
||||
const displayItems = flattenDisplayBlocks(
|
||||
buildTranscriptDisplayBlocks(rawItems),
|
||||
);
|
||||
const systemPromptIdx = displayItems.findIndex(
|
||||
(i) => i.title === "System prompt",
|
||||
);
|
||||
// Both turns produce a Prompt context — grab the first one (turn-1).
|
||||
const firstPromptContextIdx = displayItems.findIndex(
|
||||
(i) => i.title === "Prompt context",
|
||||
);
|
||||
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
|
||||
assert.ok(
|
||||
firstPromptContextIdx !== -1,
|
||||
"expected at least one Prompt context item",
|
||||
);
|
||||
assert.ok(
|
||||
systemPromptIdx < firstPromptContextIdx,
|
||||
`expected System prompt (idx ${systemPromptIdx}) before first Prompt context (idx ${firstPromptContextIdx}) in display order`,
|
||||
);
|
||||
const systemPromptRawIdx = rawItems.findIndex(
|
||||
(i) => i.title === "System prompt",
|
||||
);
|
||||
assert.equal(
|
||||
rawItems[systemPromptRawIdx].turnId ?? null,
|
||||
null,
|
||||
"system-prompt item must have turnId=null",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,16 @@ export type TranscriptState = {
|
||||
activeMessageKey: Map<string, string>;
|
||||
sealedKeys: Set<string>;
|
||||
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||
/**
|
||||
* Maps JSON-RPC request id → { itemId, optionNames }.
|
||||
* Populated when a `session/request_permission` request is ingested so the
|
||||
* matching response (which carries the same JSON-RPC id, no `method`) can
|
||||
* correlate and append the outcome to the lifecycle item.
|
||||
*/
|
||||
pendingPermissions: Map<
|
||||
string,
|
||||
{ itemId: string; optionNames: Map<string, string> }
|
||||
>;
|
||||
continuationSeq: number;
|
||||
latestSessionId: string | null;
|
||||
};
|
||||
@@ -46,6 +56,7 @@ export function createEmptyTranscriptState(): TranscriptState {
|
||||
activeMessageKey: new Map(),
|
||||
sealedKeys: new Set(),
|
||||
triggeringEventIdsByTurn: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
continuationSeq: 0,
|
||||
latestSessionId: null,
|
||||
};
|
||||
@@ -62,6 +73,10 @@ type TranscriptDraft = {
|
||||
activeMessageKey: Map<string, string>;
|
||||
sealedKeys: Set<string>;
|
||||
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||
pendingPermissions: Map<
|
||||
string,
|
||||
{ itemId: string; optionNames: Map<string, string> }
|
||||
>;
|
||||
continuationSeq: number;
|
||||
latestSessionId: string | null;
|
||||
changed: boolean;
|
||||
@@ -74,6 +89,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft {
|
||||
activeMessageKey: state.activeMessageKey,
|
||||
sealedKeys: state.sealedKeys,
|
||||
triggeringEventIdsByTurn: state.triggeringEventIdsByTurn,
|
||||
pendingPermissions: state.pendingPermissions,
|
||||
continuationSeq: state.continuationSeq,
|
||||
latestSessionId: state.latestSessionId,
|
||||
changed: false,
|
||||
@@ -178,9 +194,24 @@ function describePermissionRequest(payload: Record<string, unknown>) {
|
||||
if (title !== "Permission requested") detail.push(title);
|
||||
if (toolCallId) detail.push(`Tool call: ${toolCallId}`);
|
||||
if (options.length > 0) detail.push(`Options: ${options.join(", ")}`);
|
||||
|
||||
// Build optionId → kind map for outcome labeling on the response.
|
||||
const optionNames = new Map<string, string>();
|
||||
if (Array.isArray(params.options)) {
|
||||
for (const option of params.options) {
|
||||
const record = asRecord(option);
|
||||
const optionId = asString(record.optionId);
|
||||
const kind = asString(record.kind);
|
||||
if (optionId && kind) {
|
||||
optionNames.set(optionId, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
text: detail.join("\n"),
|
||||
optionNames,
|
||||
descriptor: {
|
||||
renderClass: "permission" as const,
|
||||
label: "Permission requested",
|
||||
@@ -195,6 +226,41 @@ function describePermissionRequest(payload: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a human-readable outcome label from a permission response.
|
||||
* kind values from ACP: allow_once, allow_always, reject_once, reject_always.
|
||||
* "reject_*" kinds are denials; anything else that is selected is an approval.
|
||||
*/
|
||||
function describePermissionOutcome(
|
||||
outcome: string,
|
||||
optionId: string | null,
|
||||
optionNames: Map<string, string>,
|
||||
): string {
|
||||
if (outcome === "cancelled") {
|
||||
return "Cancelled";
|
||||
}
|
||||
if (outcome === "selected" && optionId) {
|
||||
const kind = optionNames.get(optionId) ?? optionId;
|
||||
const isDenial = kind.startsWith("reject");
|
||||
const verb = isDenial ? "Denied" : "Approved";
|
||||
return `${verb} (${kind})`;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable map key for a JSON-RPC id, which may be a string or a finite number
|
||||
* per the spec. Using JSON.stringify avoids collisions between the number 1 and
|
||||
* the string "1". Returns null for null, undefined, or non-id values (objects,
|
||||
* booleans) so callers can gate on presence without a separate type check.
|
||||
*/
|
||||
function jsonRpcId(value: unknown): string | null {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (typeof value === "number" && Number.isFinite(value))
|
||||
return JSON.stringify(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeFreeformStatus(payload: Record<string, unknown>) {
|
||||
const statusType = asString(payload.type) ?? asString(payload.status);
|
||||
const title =
|
||||
@@ -373,6 +439,52 @@ function upsertLifecycleItem(
|
||||
});
|
||||
}
|
||||
|
||||
// Like upsertLifecycleItem but REPLACES the text on update instead of
|
||||
// appending. Used for coalescing fields (e.g. usage_update) where only the
|
||||
// latest value is meaningful — repeated updates must not accumulate.
|
||||
function replaceLifecycleItem(
|
||||
d: TranscriptDraft,
|
||||
id: string,
|
||||
renderClass: Extract<
|
||||
AgentActivityRenderClass,
|
||||
"status" | "permission" | "error"
|
||||
>,
|
||||
title: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing?.type === "lifecycle") {
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
renderClass,
|
||||
title,
|
||||
text,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "lifecycle",
|
||||
renderClass,
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
|
||||
function upsertPlan(
|
||||
d: TranscriptDraft,
|
||||
id: string,
|
||||
@@ -677,9 +789,10 @@ export function processTranscriptEvent(
|
||||
|
||||
if (method === "session/request_permission") {
|
||||
const request = describePermissionRequest(payload);
|
||||
const itemId = `permission:${ch}:${event.turnId ?? event.seq}`;
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`permission:${ch}:${event.turnId ?? event.seq}`,
|
||||
itemId,
|
||||
"permission",
|
||||
"Permission requested",
|
||||
request.text,
|
||||
@@ -688,6 +801,40 @@ export function processTranscriptEvent(
|
||||
"permission_request",
|
||||
request.descriptor,
|
||||
);
|
||||
// Index by JSON-RPC id so the response (acp_write with result.outcome,
|
||||
// no method) can correlate by id rather than by turn/seq.
|
||||
const requestId = jsonRpcId(payload.id);
|
||||
if (requestId) {
|
||||
d.pendingPermissions = new Map(d.pendingPermissions);
|
||||
d.pendingPermissions.set(requestId, {
|
||||
itemId,
|
||||
optionNames: request.optionNames,
|
||||
});
|
||||
}
|
||||
} else if (event.kind === "acp_write" && !method) {
|
||||
// Permission response: {"id": <same as request>, "result": {"outcome": {...}}}
|
||||
const responseId = jsonRpcId(payload.id);
|
||||
const result = asRecord(asRecord(payload.result).outcome);
|
||||
const outcomeKind = asString(result.outcome);
|
||||
const pending = responseId ? d.pendingPermissions.get(responseId) : null;
|
||||
if (pending && outcomeKind && responseId) {
|
||||
const optionId = asString(result.optionId) ?? null;
|
||||
const outcomeText = describePermissionOutcome(
|
||||
outcomeKind,
|
||||
optionId,
|
||||
pending.optionNames,
|
||||
);
|
||||
const existing = d.itemsById.get(pending.itemId);
|
||||
if (existing?.type === "lifecycle") {
|
||||
replaceItem(d, pending.itemId, {
|
||||
...existing,
|
||||
outcome: outcomeText,
|
||||
});
|
||||
// Remove from pending map — the outcome is now recorded.
|
||||
d.pendingPermissions = new Map(d.pendingPermissions);
|
||||
d.pendingPermissions.delete(responseId);
|
||||
}
|
||||
}
|
||||
} else if (event.kind === "acp_write" && method === "session/prompt") {
|
||||
const promptText = extractPromptText(payload);
|
||||
if (promptText) {
|
||||
@@ -722,9 +869,10 @@ export function processTranscriptEvent(
|
||||
} else if (event.kind === "acp_write" && method === "session/new") {
|
||||
// The base + persona prompts ride session/new's systemPrompt, framed by
|
||||
// the harness as [Base]/[System]. Surface them as one "System prompt" item
|
||||
// keyed per channel-session — the frame carries no session id (it predates
|
||||
// session creation), and session/new fires once per channel-session, so a
|
||||
// re-created session correctly replaces the prior item.
|
||||
// keyed per channel-session — session/new fires once per channel-session,
|
||||
// so a re-created session correctly replaces the prior item.
|
||||
// turnId: null keeps it out of turn buckets; acpSource "session/new" lets
|
||||
// the display grouper inject it before prompt-context in the prompt segment.
|
||||
const params = asRecord(payload.params);
|
||||
const systemPrompt = asString(params.systemPrompt);
|
||||
if (systemPrompt) {
|
||||
@@ -736,7 +884,8 @@ export function processTranscriptEvent(
|
||||
"System prompt",
|
||||
sections,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
{ ...ctx, turnId: null },
|
||||
"session/new",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -870,6 +1019,83 @@ export function processTranscriptEvent(
|
||||
updateType,
|
||||
`plan-update:${ch}:${turnKey}:${event.seq}`,
|
||||
);
|
||||
} else if (updateType === "current_mode_update") {
|
||||
const mode = asString(update.currentModeId) ?? "";
|
||||
if (mode) {
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`mode:${ch}:${turnKey}`,
|
||||
"status",
|
||||
"Mode",
|
||||
mode,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
}
|
||||
} else if (updateType === "usage_update") {
|
||||
const used = typeof update.used === "number" ? update.used : null;
|
||||
const size = typeof update.size === "number" ? update.size : null;
|
||||
if (used !== null && size !== null) {
|
||||
const costRecord = asRecord(update.cost);
|
||||
const costAmount =
|
||||
typeof costRecord.amount === "number" ? costRecord.amount : null;
|
||||
const costCurrency = asString(costRecord.currency);
|
||||
const costStr =
|
||||
costAmount !== null && costCurrency
|
||||
? ` ($${costAmount.toFixed(4)} ${costCurrency})`
|
||||
: "";
|
||||
replaceLifecycleItem(
|
||||
d,
|
||||
`usage:${ch}:${turnKey}`,
|
||||
"status",
|
||||
"Usage",
|
||||
`Tokens: ${used}/${size}${costStr}`,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
}
|
||||
} else if (updateType === "available_commands_update") {
|
||||
const cmds = Array.isArray(update.availableCommands)
|
||||
? update.availableCommands
|
||||
: [];
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`commands:${ch}:${turnKey}`,
|
||||
"status",
|
||||
"Commands",
|
||||
`Commands available: ${cmds.length}`,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
} else if (updateType === "config_option_update") {
|
||||
const opts = Array.isArray(update.configOptions)
|
||||
? (update.configOptions as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const optText = opts
|
||||
.map((o) => {
|
||||
const name = asString(o.name) ?? asString(o.id) ?? "?";
|
||||
const val =
|
||||
asString(o.currentValue) ??
|
||||
(typeof o.value === "boolean" ? String(o.value) : null) ??
|
||||
"";
|
||||
return val ? `${name} = ${val}` : name;
|
||||
})
|
||||
.join(", ");
|
||||
if (optText) {
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`config:${ch}:${turnKey}`,
|
||||
"status",
|
||||
"Config",
|
||||
optText,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Free-form observer status records are not part of the ACP session/update
|
||||
// union. Surface only explicit title/text payloads; leave all other
|
||||
@@ -918,6 +1144,7 @@ export function processTranscriptEvent(
|
||||
activeMessageKey: d.activeMessageKey,
|
||||
sealedKeys: d.sealedKeys,
|
||||
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
|
||||
pendingPermissions: d.pendingPermissions,
|
||||
continuationSeq: d.continuationSeq,
|
||||
latestSessionId: d.latestSessionId,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TranscriptTurnSegment =
|
||||
| {
|
||||
kind: "prompt";
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
};
|
||||
@@ -43,6 +44,12 @@ function isPromptContext(
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemPrompt(
|
||||
item: TranscriptItem,
|
||||
): item is Extract<TranscriptItem, { type: "metadata" }> {
|
||||
return item.type === "metadata" && item.acpSource === "session/new";
|
||||
}
|
||||
|
||||
function isSetupLifecycle(
|
||||
item: TranscriptItem,
|
||||
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
|
||||
@@ -57,7 +64,13 @@ type TurnBucket = {
|
||||
items: TranscriptItem[];
|
||||
};
|
||||
|
||||
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
|
||||
function classifyTurnItems(
|
||||
items: TranscriptItem[],
|
||||
externalSystemPrompt: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null,
|
||||
): TranscriptTurnSegment[] {
|
||||
const userPrompt = items.find(isUserPrompt) ?? null;
|
||||
const setupLifecycle = items.filter(isSetupLifecycle);
|
||||
const promptContext = items.find(isPromptContext) ?? null;
|
||||
@@ -79,6 +92,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
|
||||
{
|
||||
kind: "prompt",
|
||||
user: userPrompt,
|
||||
systemPrompt: externalSystemPrompt,
|
||||
context: promptContext,
|
||||
setup: setupLifecycle,
|
||||
},
|
||||
@@ -173,6 +187,11 @@ function getRenderClass(item: TranscriptItem) {
|
||||
* Build presentation-only display blocks from normalized transcript items.
|
||||
* Raw observer order is preserved in the source items; this only reorders
|
||||
* within a turn for user-facing narrative flow.
|
||||
*
|
||||
* System-prompt items (acpSource "session/new") are per-channel singles with
|
||||
* turnId=null. They are injected into the prompt segment of the first turn
|
||||
* that follows them in stream order — placing System prompt between the user
|
||||
* message bubble and the Prompt context sections in the rendered output.
|
||||
*/
|
||||
export function buildTranscriptDisplayBlocks(
|
||||
items: TranscriptItem[],
|
||||
@@ -185,10 +204,22 @@ export function buildTranscriptDisplayBlocks(
|
||||
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
|
||||
> = [];
|
||||
|
||||
// System-prompt items (turnId=null, acpSource "session/new") accumulate here
|
||||
// until consumed by the first turn that follows them in stream order.
|
||||
let pendingSystemPrompt: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
const turnId = item.turnId;
|
||||
if (!turnId) {
|
||||
displayOrder.push({ kind: "single", item });
|
||||
if (isSystemPrompt(item)) {
|
||||
// Hold system-prompt for injection into the next turn's prompt segment.
|
||||
pendingSystemPrompt = item;
|
||||
} else {
|
||||
displayOrder.push({ kind: "single", item });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -201,6 +232,9 @@ export function buildTranscriptDisplayBlocks(
|
||||
bucket.items.push(item);
|
||||
}
|
||||
|
||||
// Track per-turn injected system-prompt so multi-turn streams don't re-inject.
|
||||
const consumedSystemPrompts = new Set<string>();
|
||||
|
||||
for (const entry of displayOrder) {
|
||||
if (entry.kind === "single") {
|
||||
blocks.push({ kind: "single", item: entry.item });
|
||||
@@ -212,7 +246,22 @@ export function buildTranscriptDisplayBlocks(
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = classifyTurnItems(bucket.items);
|
||||
// Inject system-prompt into the first turn that has a user-prompt item.
|
||||
// On subsequent turns, system-prompt stays null (session/new doesn't re-fire).
|
||||
let systemPromptForTurn: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null;
|
||||
if (
|
||||
pendingSystemPrompt &&
|
||||
!consumedSystemPrompts.has(pendingSystemPrompt.id) &&
|
||||
bucket.items.some(isUserPrompt)
|
||||
) {
|
||||
systemPromptForTurn = pendingSystemPrompt;
|
||||
consumedSystemPrompts.add(pendingSystemPrompt.id);
|
||||
}
|
||||
|
||||
const segments = classifyTurnItems(bucket.items, systemPromptForTurn);
|
||||
if (segments.length > 0) {
|
||||
blocks.push({
|
||||
kind: "turn",
|
||||
@@ -222,6 +271,16 @@ export function buildTranscriptDisplayBlocks(
|
||||
}
|
||||
}
|
||||
|
||||
// If system-prompt was never consumed (no session/prompt followed — e.g.
|
||||
// session/new arrived without a subsequent turn, or the stream is still
|
||||
// incomplete), emit it as a standalone single so it remains visible.
|
||||
if (
|
||||
pendingSystemPrompt &&
|
||||
!consumedSystemPrompts.has(pendingSystemPrompt.id)
|
||||
) {
|
||||
blocks.push({ kind: "single", item: pendingSystemPrompt });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -243,6 +302,9 @@ export function flattenDisplayBlocks(
|
||||
} else if (segment.kind === "prompt") {
|
||||
result.push(segment.user);
|
||||
result.push(...segment.setup);
|
||||
if (segment.systemPrompt) {
|
||||
result.push(segment.systemPrompt);
|
||||
}
|
||||
if (segment.context) {
|
||||
result.push(segment.context);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
getActivityHeadline,
|
||||
isMeaningfulItem,
|
||||
isSpineItem,
|
||||
} from "./agentSessionTranscriptPresentation.ts";
|
||||
|
||||
const baseTimestamp = "2026-06-14T19:00:00.000Z";
|
||||
@@ -47,7 +48,7 @@ test("getActivityHeadline formats tool titles and assistant text", () => {
|
||||
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
|
||||
});
|
||||
|
||||
test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
|
||||
test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => {
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "life:1",
|
||||
@@ -57,16 +58,45 @@ test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
|
||||
timestamp: baseTimestamp,
|
||||
}),
|
||||
false,
|
||||
"turn started is lifecycle noise → not meaningful",
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "meta:1",
|
||||
id: "meta:raw",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Raw ACP payload",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "raw_json_rpc",
|
||||
}),
|
||||
false,
|
||||
"raw_json_rpc metadata is infrastructure noise → not meaningful",
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "meta:ctx",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Prompt context",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "session/prompt:context",
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
"prompt context metadata is semantic → meaningful",
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "meta:sys",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "System prompt",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
}),
|
||||
true,
|
||||
"system prompt metadata (no acpSource) is semantic → meaningful",
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
@@ -118,3 +148,110 @@ test("isMeaningfulItem ignores suppressed tools", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const metadataSystemPrompt = {
|
||||
id: "meta:sys",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "System prompt",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
};
|
||||
|
||||
const metadataPromptContext = {
|
||||
id: "meta:ctx",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Prompt context",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "session/prompt:context",
|
||||
};
|
||||
|
||||
test("isSpineItem excludes metadata items (reads recede)", () => {
|
||||
assert.equal(
|
||||
isSpineItem(metadataSystemPrompt),
|
||||
false,
|
||||
"system prompt metadata is not spine work",
|
||||
);
|
||||
assert.equal(
|
||||
isSpineItem(metadataPromptContext),
|
||||
false,
|
||||
"prompt context metadata is not spine work",
|
||||
);
|
||||
assert.equal(isSpineItem(makeTool()), true, "tool items are spine work");
|
||||
assert.equal(
|
||||
isSpineItem(makeMessage()),
|
||||
true,
|
||||
"message items are spine work",
|
||||
);
|
||||
assert.equal(
|
||||
isSpineItem({
|
||||
id: "meta:raw",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "Raw ACP payload",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "raw_json_rpc",
|
||||
}),
|
||||
false,
|
||||
"raw_json_rpc is already filtered by isMeaningfulItem → not spine",
|
||||
);
|
||||
});
|
||||
|
||||
test("isSpineItem: metadata still meaningful via isMeaningfulItem (feed visibility unchanged)", () => {
|
||||
// isMeaningfulItem must still return true for non-raw metadata — the feed
|
||||
// renders metadata items independently of isSpineItem.
|
||||
assert.equal(isMeaningfulItem(metadataSystemPrompt), true);
|
||||
assert.equal(isMeaningfulItem(metadataPromptContext), true);
|
||||
});
|
||||
|
||||
test("two-tier headline: metadata excluded when spine work is present", () => {
|
||||
// Simulate what BotActivityBar does: when any spine item exists, only spine
|
||||
// items are eligible for the headline rotation.
|
||||
const transcript = [metadataSystemPrompt, makeTool()];
|
||||
const hasSpine = transcript.some(isSpineItem);
|
||||
const passFilter = hasSpine ? isSpineItem : isMeaningfulItem;
|
||||
|
||||
const headlines = transcript
|
||||
.filter(passFilter)
|
||||
.map((item) => getActivityHeadline(item))
|
||||
.filter(Boolean);
|
||||
|
||||
assert.ok(
|
||||
!headlines.includes("System prompt"),
|
||||
"System prompt should not headline when spine work exists",
|
||||
);
|
||||
assert.ok(
|
||||
headlines.some((h) => h?.includes("Send Message")),
|
||||
"Tool headline should appear",
|
||||
);
|
||||
});
|
||||
|
||||
test("two-tier headline: metadata headlines when it is the only activity (session start / idle)", () => {
|
||||
// When no spine items exist, fall back to isMeaningfulItem so the bar is not
|
||||
// empty at session start.
|
||||
const transcript = [metadataSystemPrompt, metadataPromptContext];
|
||||
const hasSpine = transcript.some(isSpineItem);
|
||||
const passFilter = hasSpine ? isSpineItem : isMeaningfulItem;
|
||||
|
||||
const headlines = transcript
|
||||
.filter(passFilter)
|
||||
.map((item) => getActivityHeadline(item))
|
||||
.filter(Boolean);
|
||||
|
||||
assert.ok(
|
||||
headlines.includes("System prompt"),
|
||||
"System prompt should headline when no spine work exists",
|
||||
);
|
||||
assert.ok(
|
||||
headlines.includes("Prompt context"),
|
||||
"Prompt context should headline when no spine work exists",
|
||||
);
|
||||
});
|
||||
|
||||
// Render-tier tests (raw_json_rpc → <pre>, non-raw → polished accordion) live in
|
||||
// activityRenderClasses/RawRailActivity.render.test.mjs — they use
|
||||
// renderToStaticMarkup and would fail if the isRawPayload branch in
|
||||
// RawRailActivity were removed.
|
||||
|
||||
@@ -47,7 +47,7 @@ function isLifecycleNoise(
|
||||
return LIFECYCLE_NOISE.has(item.title.toLowerCase());
|
||||
}
|
||||
|
||||
/** Whether an item should contribute to the "Now" summary and headline scan. */
|
||||
/** Whether an item should contribute to the headline scan (noise gate). */
|
||||
export function isMeaningfulItem(item: TranscriptItem): boolean {
|
||||
if (item.type === "tool" && item.renderClass === "suppressed") {
|
||||
return false;
|
||||
@@ -56,7 +56,26 @@ export function isMeaningfulItem(item: TranscriptItem): boolean {
|
||||
return !isLifecycleNoise(item);
|
||||
}
|
||||
if (item.type === "metadata") {
|
||||
return false;
|
||||
// Raw JSON-RPC frames ("Raw ACP payload") are infrastructure noise; all
|
||||
// other metadata items (system prompt, prompt context) are semantically
|
||||
// meaningful and visible in the feed.
|
||||
return item.acpSource !== "raw_json_rpc";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item is "spine" work — eligible to headline over setup/context.
|
||||
* Tools, messages, thoughts, plans, and meaningful lifecycle events qualify.
|
||||
* Metadata items (system prompt, prompt context) are reads that should recede
|
||||
* when real work is present; they are NOT spine items.
|
||||
*
|
||||
* Used by BotActivityBar for the two-tier headline scan:
|
||||
* 1. Collect spine headlines first.
|
||||
* 2. If none found, fall back to including metadata so the bar isn't empty at
|
||||
* session start / idle.
|
||||
*/
|
||||
export function isSpineItem(item: TranscriptItem): boolean {
|
||||
if (!isMeaningfulItem(item)) return false;
|
||||
return item.type !== "metadata";
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ export type TranscriptItem =
|
||||
renderClass: "status" | "permission" | "error";
|
||||
title: string;
|
||||
text: string;
|
||||
/** Resolved outcome for permission items (e.g. "Approved (allow_once)", "Denied (reject_once)", "Cancelled"). */
|
||||
outcome?: string;
|
||||
timestamp: string;
|
||||
descriptor?: AgentActivityDescriptor;
|
||||
acpSource?: TranscriptAcpSource;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||
import {
|
||||
getActivityHeadline,
|
||||
isMeaningfulItem,
|
||||
isSpineItem,
|
||||
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
@@ -67,9 +68,15 @@ export function BotActivityComposerAction({
|
||||
? transcript.filter((item) => item.channelId === channelId)
|
||||
: transcript;
|
||||
|
||||
// Two-tier scan: spine items first (reads recede when real work is present).
|
||||
// If no spine headlines are found (session start / idle), fall back to all
|
||||
// meaningful items so the bar isn't left empty.
|
||||
const passFilter: (item: (typeof scopedTranscript)[number]) => boolean =
|
||||
scopedTranscript.some(isSpineItem) ? isSpineItem : isMeaningfulItem;
|
||||
|
||||
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
|
||||
const item = scopedTranscript[i];
|
||||
if (!isMeaningfulItem(item)) {
|
||||
if (!passFilter(item)) {
|
||||
continue;
|
||||
}
|
||||
const headline = getActivityHeadline(item);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { injectObserverEventsForE2E } from "@/features/agents/observerRelayStore";
|
||||
import {
|
||||
CUSTOM_EMOJI_SET_D_TAG,
|
||||
KIND_EMOJI_SET,
|
||||
@@ -663,6 +664,19 @@ declare global {
|
||||
channelId: string;
|
||||
turnId: string;
|
||||
}) => void;
|
||||
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
|
||||
agentPubkey: string;
|
||||
events: Array<{
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
kind: string;
|
||||
agentIndex: number | null;
|
||||
channelId: string | null;
|
||||
sessionId: string | null;
|
||||
turnId: string | null;
|
||||
payload: unknown;
|
||||
}>;
|
||||
}) => void;
|
||||
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
||||
clientId: string;
|
||||
contexts: Record<string, number>;
|
||||
@@ -6942,6 +6956,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
},
|
||||
]);
|
||||
};
|
||||
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => {
|
||||
injectObserverEventsForE2E(agentPubkey, events);
|
||||
};
|
||||
const meshNodeStatus = (
|
||||
state: "off" | "running",
|
||||
mode: "serve" | "client" | null,
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/observer-feed";
|
||||
|
||||
// A running managed agent whose pubkey maps to the "agents" channel.
|
||||
// Using tyler's pubkey so the mock bridge already knows this identity.
|
||||
const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
|
||||
const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
|
||||
const NOW = new Date("2025-06-15T12:00:00Z").toISOString();
|
||||
|
||||
const MANAGED_AGENTS = [
|
||||
{
|
||||
pubkey: OBSERVER_AGENT_PUBKEY,
|
||||
name: "Observer Agent",
|
||||
status: "running" as const,
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
];
|
||||
|
||||
// Helper: wait until the seed hook is available in the page.
|
||||
async function waitForSeedHook(page: import("@playwright/test").Page) {
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: open the observer feed panel by navigating to #agents, clicking the
|
||||
// agent avatar to open the profile panel, then clicking "View activity".
|
||||
async function openObserverFeedPanel(
|
||||
page: import("@playwright/test").Page,
|
||||
agentPubkey: string,
|
||||
) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForSeedHook(page);
|
||||
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
|
||||
// Click any message row button to open the profile panel.
|
||||
const messageRow = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
|
||||
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
|
||||
await messageRow.first().getByRole("button").first().click();
|
||||
|
||||
const profilePanel = page.getByTestId("user-profile-panel");
|
||||
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click "View activity" to open the observer feed panel.
|
||||
const activityBtn = page.getByTestId(
|
||||
`user-profile-view-activity-${agentPubkey}`,
|
||||
);
|
||||
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
|
||||
await activityBtn.click();
|
||||
|
||||
const feedPanel = page.getByTestId("agent-session-thread-panel");
|
||||
await expect(feedPanel).toBeVisible({ timeout: 10_000 });
|
||||
return feedPanel;
|
||||
}
|
||||
|
||||
// Helper: seed observer events into the store and wait for the panel to update.
|
||||
async function seedObserverEvents(
|
||||
page: import("@playwright/test").Page,
|
||||
agentPubkey: string,
|
||||
events: Array<{
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
kind: string;
|
||||
agentIndex: number | null;
|
||||
channelId: string | null;
|
||||
sessionId: string | null;
|
||||
turnId: string | null;
|
||||
payload: unknown;
|
||||
}>,
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ pubkey, evts }) => {
|
||||
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
|
||||
agentPubkey: pubkey,
|
||||
events: evts,
|
||||
});
|
||||
},
|
||||
{ pubkey: agentPubkey, evts: events },
|
||||
);
|
||||
// Let React re-render after the store update.
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function settleAnimations(panel: import("@playwright/test").Locator) {
|
||||
await panel.evaluate((el) =>
|
||||
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("observer feed screenshots", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (err) => {
|
||||
console.error(
|
||||
"PAGE ERROR:",
|
||||
err.message,
|
||||
err.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
);
|
||||
});
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("01 — prompt context inline (collapsed-but-labeled)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
// session/prompt event: the per-turn prompt context that #1381 stopped
|
||||
// rendering inline. The payload contains sections that parsePromptText
|
||||
// extracts and the transcript renders as a collapsed PromptContextInline.
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The inline context element should be visible with collapsed sections.
|
||||
await expect(
|
||||
feedPanel.getByTestId("transcript-prompt-context-inline"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/01-prompt-context-inline.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("02 — system prompt title restored", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
// session/new event without a subsequent session/prompt: the system-prompt
|
||||
// item is never consumed by a turn bucket, so the grouper emits it as a
|
||||
// standalone single rendered by RawRailActivity with the "System prompt"
|
||||
// title. (This is the isolated test; see shot 11 for the full turn bundle.)
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: null,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The system prompt rail item should show the restored title.
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/02-system-prompt-title-restored.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("03 — permission outcome (approved)", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
// Permission request followed by a selected (approved) response,
|
||||
// correlated by JSON-RPC id (fix #3: was broken for numeric ids before).
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
// Request
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 42,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
title: "Read file system",
|
||||
message: "Agent wants to read ~/.config/goose/config.yaml",
|
||||
options: [
|
||||
{ optionId: "opt-allow", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "opt-deny", kind: "reject_once", name: "Deny" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Response (numeric id 42 — the exact case fix #3 restores)
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 42,
|
||||
result: {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: "opt-allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The permission row should show the "Approved (allow_once)" outcome.
|
||||
await expect(feedPanel.getByText(/Approved.*allow_once/)).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/03-permission-approved.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("04 — permission outcome (cancelled)", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
// Request
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: "perm-cancelled-1",
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
title: "Write to output file",
|
||||
options: [
|
||||
{ optionId: "opt-yes", kind: "allow_once", name: "Allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Cancelled response
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: "perm-cancelled-1",
|
||||
result: {
|
||||
outcome: {
|
||||
outcome: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("Cancelled")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/04-permission-cancelled.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("05 — prompt context inline (sections expanded)", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
feedPanel.getByTestId("transcript-prompt-context-inline"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click each section accordion button to expand it.
|
||||
const sectionButtons = feedPanel
|
||||
.getByTestId("transcript-prompt-context-sections")
|
||||
.getByRole("button");
|
||||
for (const btn of await sectionButtons.all()) {
|
||||
await btn.click();
|
||||
}
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/05-prompt-context-expanded.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("06 — system prompt sections expanded", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: null,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Open the outer ActivityRow <details> to reveal the section content,
|
||||
// then click each section accordion button to expand it (mirrors shot 05 —
|
||||
// system-prompt sections now render as React button accordions, not native
|
||||
// <details> elements).
|
||||
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
|
||||
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
|
||||
for (const details of el.querySelectorAll("details")) {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
const sectionButtons = feedPanel
|
||||
.getByTestId("transcript-metadata-item")
|
||||
.getByTestId("transcript-prompt-context-sections")
|
||||
.getByRole("button");
|
||||
const allSectionButtons = await sectionButtons.all();
|
||||
expect(allSectionButtons.length).toBeGreaterThan(0);
|
||||
for (const btn of allSectionButtons) {
|
||||
await btn.click();
|
||||
}
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/06-system-prompt-expanded.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("07 — current_mode_update lifecycle status line", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-007",
|
||||
turnId: "turn-007",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-007",
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("Mode")).toBeVisible({ timeout: 5_000 });
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/07-current-mode-update.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("08 — usage_update lifecycle status line (coalesced)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
// Two usage frames — only the last should be visible.
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-008",
|
||||
turnId: "turn-008",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-008",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 1200,
|
||||
size: 8192,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-008",
|
||||
turnId: "turn-008",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-008",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 3450,
|
||||
size: 8192,
|
||||
cost: { amount: 0.0018, currency: "USD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("Usage")).toBeVisible({ timeout: 5_000 });
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/08-usage-update-coalesced.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("09 — available_commands_update lifecycle status line", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-009",
|
||||
turnId: "turn-009",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-009",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Create a structured plan for the task",
|
||||
},
|
||||
{
|
||||
name: "research_codebase",
|
||||
description: "Research and understand the codebase",
|
||||
},
|
||||
{
|
||||
name: "execute_steps",
|
||||
description: "Execute the planned steps",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("Commands")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/09-available-commands-update.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("10 — config_option_update lifecycle status line", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-010",
|
||||
turnId: "turn-010",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-010",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
currentValue: "gpt-4o",
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
type: "select",
|
||||
currentValue: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("Config")).toBeVisible({ timeout: 5_000 });
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/10-config-option-update.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("11 — first-turn ordering: user bubble → System prompt → Prompt context", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
// Full realistic pool.rs first-turn wire sequence:
|
||||
// turn_started → session/new → session_resolved → session/prompt
|
||||
// Verifies the ordering fix: System prompt renders between the user message
|
||||
// bubble and the Prompt context inline block (not after it).
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: null,
|
||||
turnId: "turn-001",
|
||||
payload: { source: "channel", triggeringEventIds: [] },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: null,
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: NOW,
|
||||
kind: "session_resolved",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: { sessionId: "session-001", isNewSession: true },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: "turn-001",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// User message bubble should be visible (anchors the prompt bundle).
|
||||
await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible(
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// System prompt should appear inside the bundle, above prompt context.
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(feedPanel.getByText("Prompt context")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
await feedPanel.screenshot({
|
||||
path: `${SHOTS}/11-first-turn-ordering.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user