mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(task-chat): show harness status notices as chips, out of the answer (#19267)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. The runner turns harness events from Codex and Claude into canonical provider events, and the task chat shows them. > - Harnesses report their own lifecycle: context compaction, retries, model fallback, and warnings. Paperclip lost or mangled these reports. > - Claude (claude-agent-acp 0.73) sends compaction, model-fallback and warning banners as ordinary message text, so "Compacting..." ended up inside the agent's answer and the issue comment. > - Codex sends `error` with the text in `error.message` and a `willRetry` flag. Paperclip read `params.message`, so every Codex error said "Provider notice", and every retry looked fatal. > - This pull request keeps these reports out of the answer and shows them as small status chips between the turn's activity. > - The benefit is that users can see why an agent paused, retried or forgot earlier context, and the answer text stays clean. ## Linked Issues or Issue Description **Subsystem affected** Native runner provider-event mapping (TypeScript driver, ACPX sidecar and Rust runner-core) and the task chat transcript. **Problem or motivation** Claude status banners are mixed into assistant answer text. Codex error notices lose their message and their retry state. The task chat hid all of these notices, and settled turns stripped every provider notice. **Proposed solution** Classify Claude banners as provider notices at the ACPX boundary. Map Codex `TurnError` fields and `willRetry` correctly. Render status notices as inline chips. A retry chip fades after later provider work. A final error shows the real provider message. **Alternatives considered** Patch claude-agent-acp to tag banners in `_meta`. ACPX keeps only `origin`, `kind` and `source` from `_meta`, and other sessions own the ACPX `_meta` passthrough work. Banner chunks already have no `messageId`, and model output always has one, so the classification needs no adapter patch. **Roadmap alignment** This makes existing harness signals visible. It adds no new protocol schema. It uses the existing `paperclip.provider.notice.v1` `category` and `details` fields. ## What Changed - Codex `error` notifications (TypeScript `provider-events.ts` and Rust `runner-core/src/provider_events.rs`): read `error.message`, map `willRetry: true` to a recoverable `provider_retry` notice, keep category `error` for a final error, and add the `CodexErrorInfo` kind, HTTP status, and the "Reconnecting... n/m" attempt count as notice details. Retries in one turn share one notice id. - New `claude-status-banners.ts`: classifies the claude-agent-acp compaction, model-fallback and SDK informational banners. A chunk must have no `messageId` and match a known banner exactly. - ACPX sidecar and the TypeScript ACPX driver emit these banners as `provider_notice` events instead of answer text. Compaction start and end share one notice key and carry a `Phase` detail. - Rust runner-core accepts an optional `noticeKey` and bounded `details` on ACPX `provider_notice` events. - UI: new `TaskChatStatusNotice` chip and `status-notice-presentation.ts`. Status notices become top-level timeline rows, render as chips wherever protocol cards render, and stay visible in settled (historical) turns. Generic warnings stay hidden as before. - New tokens `--tc-status-notice-warning-*` in `ui/src/index.css`. - Storybook story `Tasks and conversations/.../Task chat/Status notices` with eight states. - `doc/run-log-events.md`: describes the status notice categories. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat src/components/transcript` - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/provider-events.test.ts src/drivers/acpx/claude-status-banners.test.ts src/drivers/acpx/codex-acpx-driver.test.ts src/cli/acpx-runtime-sidecar.test.ts` - `cargo test -p paperclip-runner-core --lib provider_events` and `--test acpx_provider_events` - `pnpm check:token-gates` - Storybook: open `product-task-chat-status-notices--all-states-gallery`. - Live test-drive on an isolated worktree instance: - Codex (gpt-5.6-sol, app-server 0.160): a local pass-through proxy in front of the real OpenAI API cut the first two response streams. Codex ran its own retries. The chat showed an amber "Reconnecting" chip live, then "Recovered after retry" once work continued. With every stream cut, Codex made five retries and then sent a final error with the real message. - Claude (claude-haiku-5-5 through claude-agent-acp 0.73): a long reading task triggered Claude's own auto-compaction twice. The chat showed "Compacting context…" live and "Context compacted" afterwards. The final answer and the issue comment contained no compaction text. - Not verified live: model fallback and Claude informational banners. They need a model refusal or a blocking hook. Unit tests cover them. ## Risks - Low to medium. A Claude model message that is exactly a banner text and has no `messageId` would become a notice. Model output from claude-agent-acp always carries a `messageId`. - Older stored Codex `error` notices (category `error`, not recoverable) now render as a red row instead of being hidden. - Claude retries (`api_retry`) are still not visible. claude-agent-acp reports them only to clients that negotiate session-failure records, and ACPX drops `_meta`. This is a follow-up that depends on the ACPX `_meta` passthrough work. - On a failed Codex turn the thread collapses to a summary, so the red row does not show there. The existing task banner already shows the real provider message. - Some local suites failed on this machine. They do not touch the changed code: Cursor Cloud staging-custody tests, the ACPX Claude dependency-graph pin test (a timeout), and the Rust `task_memory` monitor tests. CI is the reference for these. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use, code execution and browser automation. Default context window. Extended reasoning was not enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Refs paperclipai/paperclip#19252 (plan card precedent). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4171af889
commit
d361f92071
26 files changed
+1373
-15
No files matched your search
@@ -591,6 +591,19 @@ changes, and unrelated thread information do not create a provider notice in
|
||||
chat. Chat also omits the matching notice stored by older runners. Real provider
|
||||
warnings and errors remain visible.
|
||||
|
||||
Harness status notices use `provider.notice.recorded` with a status category.
|
||||
Codex `error` notifications read `error.message`. A notification with
|
||||
`willRetry: true` becomes a recoverable `provider_retry` notice. A final error
|
||||
keeps category `error`. The `CodexErrorInfo` kind, the HTTP status, and the
|
||||
reconnect attempt count become notice details. Claude compaction, model
|
||||
fallback, and SDK informational banners arrive as ACP message text without a
|
||||
message identity. The ACPX layer records them as `context_compaction`,
|
||||
`model_fallback`, or `provider_informational` notices instead of answer text.
|
||||
Compaction start and end share one notice identity and carry a `Phase` detail.
|
||||
The task chat shows these notices as inline status chips. A retry chip fades
|
||||
after later provider work. A final provider error shows the real provider
|
||||
message. Generic warnings stay out of chat as before.
|
||||
|
||||
Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry.
|
||||
|
||||
An unstarted legacy conversation retry waiting for execution cleanup retains
|
||||
|
||||
@@ -562,6 +562,19 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
|
||||
"provider_notice" => {
|
||||
bounded_nonempty_text(payload, "category", 160, "provider notice category")?;
|
||||
bounded_nonempty_text(payload, "summary", 4_000, "provider notice summary")?;
|
||||
bounded_optional_text(payload, "severity", 16, "provider notice severity")?;
|
||||
bounded_optional_text(payload, "noticeKey", 160, "provider notice key")?;
|
||||
if let Some(details) = optional_array(payload, "details", "provider notice details")? {
|
||||
if details.len() > 64 {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX provider notice details exceed their bound",
|
||||
));
|
||||
}
|
||||
for detail in details {
|
||||
bounded_nonempty_text(detail, "name", 160, "provider notice detail name")?;
|
||||
bounded_optional_text(detail, "value", 4_000, "provider notice detail value")?;
|
||||
}
|
||||
}
|
||||
AcpxRuntimeEventKind::ProviderNotice
|
||||
}
|
||||
"error" => {
|
||||
|
||||
@@ -608,6 +608,84 @@ fn codex_terminal_error(params: &Value, event_type: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex app-server `error` carries a `TurnError` under `error` plus
|
||||
/// `willRetry`. A retried error is a transient status notice; only a final
|
||||
/// error is fatal. Older shapes kept the text at the top level.
|
||||
fn codex_error_notice(params: &Value) -> Value {
|
||||
let error = params.get("error").unwrap_or(&Value::Null);
|
||||
let will_retry = params.get("willRetry").and_then(Value::as_bool) == Some(true);
|
||||
let info = error.get("codexErrorInfo");
|
||||
let info_kind = match info {
|
||||
Some(Value::String(kind)) => kind.clone(),
|
||||
Some(Value::Object(map)) => map.keys().next().cloned().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let http_status = info
|
||||
.and_then(|value| value.get(&info_kind))
|
||||
.and_then(|value| value.get("httpStatusCode"))
|
||||
.and_then(Value::as_u64);
|
||||
let summary = [
|
||||
string(error.get("message")),
|
||||
string(params.get("summary")),
|
||||
string(params.get("message")),
|
||||
string(params.get("details")),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|value| !value.trim().is_empty())
|
||||
.unwrap_or("Provider notice");
|
||||
// Codex reports stream retries as "Reconnecting... {attempt}/{max}" with
|
||||
// the actual cause in additionalDetails.
|
||||
let attempt = summary
|
||||
.trim()
|
||||
.strip_prefix("Reconnecting...")
|
||||
.and_then(|rest| rest.trim().split_once('/'))
|
||||
.and_then(|(attempt, max)| {
|
||||
Some((
|
||||
attempt.trim().parse::<u32>().ok()?,
|
||||
max.trim().parse::<u32>().ok()?,
|
||||
))
|
||||
});
|
||||
let additional = string(error.get("additionalDetails")).trim();
|
||||
let summary = if attempt.is_some() && !additional.is_empty() {
|
||||
additional
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
let mut details = Vec::new();
|
||||
if let Some((attempt, max)) = attempt {
|
||||
details.push(json!({"name": "Attempt", "value": attempt.to_string()}));
|
||||
details.push(json!({"name": "Max attempts", "value": max.to_string()}));
|
||||
}
|
||||
if !info_kind.is_empty() {
|
||||
details.push(json!({"name": "Reason", "value": bounded_text(&info_kind, 160)}));
|
||||
}
|
||||
if let Some(status) = http_status {
|
||||
details.push(json!({"name": "HTTP status", "value": status.to_string()}));
|
||||
}
|
||||
if !additional.is_empty() && summary != additional {
|
||||
details.push(json!({"name": "Additional details", "value": bounded_text(additional, MAX_TEXT_CHARS)}));
|
||||
}
|
||||
let turn_id = string(params.get("turnId"));
|
||||
let mut notice = json!({
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
// Retries within one turn update one chip; the final error is its own row.
|
||||
"noticeId": stable_id(
|
||||
&format!("codex-error-{}-{}", if will_retry { "retry" } else { "final" }, turn_id),
|
||||
"codex-notice",
|
||||
),
|
||||
"severity": if will_retry { "warning" } else { "error" },
|
||||
"category": if will_retry { "provider_retry" } else { "error" },
|
||||
"scope": "turn",
|
||||
"recoverable": will_retry,
|
||||
"userActionable": false,
|
||||
"summary": bounded_text(summary.trim(), MAX_TEXT_CHARS),
|
||||
});
|
||||
if !details.is_empty() {
|
||||
notice["details"] = Value::Array(details);
|
||||
}
|
||||
notice
|
||||
}
|
||||
|
||||
/// Codex wraps MCP startup errors in sentences that repeat what the notice
|
||||
/// already says, e.g. "MCP client for `github` failed to start: MCP startup
|
||||
/// failed: <reason>"; keep only the provider's reason.
|
||||
@@ -988,6 +1066,12 @@ pub fn normalize_codex_notification_in(
|
||||
}),
|
||||
)
|
||||
}
|
||||
"error" => push(
|
||||
&mut events,
|
||||
"provider.notice.recorded",
|
||||
EventPriority::P0,
|
||||
codex_error_notice(params),
|
||||
),
|
||||
// Only a failed start is user-facing: the agent then runs without that
|
||||
// server's tools. starting/ready/cancelled stay in the raw run log.
|
||||
"mcpServer/startupStatus/updated"
|
||||
@@ -1043,7 +1127,7 @@ pub fn normalize_codex_notification_in(
|
||||
}),
|
||||
)
|
||||
}
|
||||
"error" | "warning" | "deprecationNotice" | "configWarning" => push(
|
||||
"warning" | "deprecationNotice" | "configWarning" => push(
|
||||
&mut events,
|
||||
"provider.notice.recorded",
|
||||
EventPriority::P0,
|
||||
@@ -1331,13 +1415,41 @@ pub fn normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall => {
|
||||
normalize_acpx_tool_call(payload, &item_id, tool_operation.unwrap_or("unknown"))
|
||||
}
|
||||
AcpxRuntimeEventKind::ProviderNotice => vec![acpx_notice(
|
||||
&item_id,
|
||||
string(payload.get("severity")),
|
||||
string(payload.get("category")),
|
||||
string(payload.get("summary")),
|
||||
false,
|
||||
)],
|
||||
AcpxRuntimeEventKind::ProviderNotice => {
|
||||
// A provider-supplied key pairs lifecycle notices (compaction start
|
||||
// and end) into one notice within the turn.
|
||||
let notice_id = match string(payload.get("noticeKey")) {
|
||||
"" => item_id.clone(),
|
||||
key => stable_id(&format!("{turn_id}:{key}"), fallback_item_id),
|
||||
};
|
||||
let mut event = acpx_notice(
|
||||
¬ice_id,
|
||||
string(payload.get("severity")),
|
||||
string(payload.get("category")),
|
||||
string(payload.get("summary")),
|
||||
false,
|
||||
);
|
||||
if let Some(details) = payload.get("details").and_then(Value::as_array) {
|
||||
let details: Vec<Value> = details
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter_map(|detail| {
|
||||
let name = detail.get("name").and_then(Value::as_str)?;
|
||||
let value = detail.get("value").and_then(Value::as_str)?;
|
||||
(!name.trim().is_empty()).then(|| {
|
||||
json!({
|
||||
"name": bounded_text(name, 160),
|
||||
"value": bounded_text(value, MAX_TEXT_CHARS),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !details.is_empty() {
|
||||
event.payload["details"] = Value::Array(details);
|
||||
}
|
||||
}
|
||||
vec![event]
|
||||
}
|
||||
AcpxRuntimeEventKind::Error => vec![acpx_notice(
|
||||
&item_id,
|
||||
"error",
|
||||
@@ -1876,6 +1988,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_codex_turn_error_text_retries_and_error_info() {
|
||||
let retry = normalize_codex_notification(
|
||||
"error",
|
||||
&json!({
|
||||
"turnId": "turn-1", "willRetry": true,
|
||||
"error": {
|
||||
"message": "Reconnecting... 2/5",
|
||||
"codexErrorInfo": {"responseStreamDisconnected": {"httpStatusCode": 502}},
|
||||
"additionalDetails": "stream closed before response.completed"
|
||||
}
|
||||
}),
|
||||
);
|
||||
let notice = &retry[0].payload;
|
||||
assert_eq!(notice["summary"], "stream closed before response.completed");
|
||||
assert_eq!(notice["category"], "provider_retry");
|
||||
assert_eq!(notice["severity"], "warning");
|
||||
assert_eq!(notice["recoverable"], true);
|
||||
assert_eq!(
|
||||
notice["details"],
|
||||
json!([
|
||||
{"name": "Attempt", "value": "2"},
|
||||
{"name": "Max attempts", "value": "5"},
|
||||
{"name": "Reason", "value": "responseStreamDisconnected"},
|
||||
{"name": "HTTP status", "value": "502"},
|
||||
])
|
||||
);
|
||||
let fatal = normalize_codex_notification(
|
||||
"error",
|
||||
&json!({"turnId": "turn-1", "willRetry": false,
|
||||
"error": {"message": "You've hit your usage limit.", "codexErrorInfo": "usageLimitExceeded"}}),
|
||||
);
|
||||
assert_eq!(fatal[0].payload["category"], "error");
|
||||
assert_eq!(fatal[0].payload["recoverable"], false);
|
||||
assert_eq!(fatal[0].payload["summary"], "You've hit your usage limit.");
|
||||
assert_ne!(fatal[0].payload["noticeId"], notice["noticeId"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_failed_codex_mcp_startup_to_user_facing_notice() {
|
||||
let events = normalize_codex_notification(
|
||||
|
||||
@@ -736,3 +736,33 @@ fn leaves_operational_semantic_and_terminal_events_to_the_adapter() {
|
||||
.is_empty());
|
||||
assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_status_notices_pair_by_key_and_keep_details() {
|
||||
let notice = |phase: &str| {
|
||||
normalize(
|
||||
AcpxRuntimeEventKind::ProviderNotice,
|
||||
json!({
|
||||
"type": "provider_notice", "severity": "info", "category": "context_compaction",
|
||||
"summary": "Compacting context", "noticeKey": "compaction-1",
|
||||
"details": [{"name": "Phase", "value": phase}]
|
||||
}),
|
||||
)
|
||||
};
|
||||
let started = notice("running");
|
||||
let completed = notice("completed");
|
||||
assert_eq!(
|
||||
started[0].payload["noticeId"],
|
||||
completed[0].payload["noticeId"]
|
||||
);
|
||||
assert_eq!(started[0].payload["category"], "context_compaction");
|
||||
assert_eq!(
|
||||
completed[0].payload["details"],
|
||||
json!([{"name": "Phase", "value": "completed"}])
|
||||
);
|
||||
let plain = normalize(
|
||||
AcpxRuntimeEventKind::ProviderNotice,
|
||||
json!({"type": "provider_notice", "category": "acpx_x", "summary": "x"}),
|
||||
);
|
||||
assert!(plain[0].payload.get("details").is_none());
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "../drivers/acpx/environment.js";
|
||||
import { createKimiMessageProjection } from "../drivers/acpx/kimi-message-boundary.js";
|
||||
import { classifyClaudeStatusBanner } from "../drivers/acpx/claude-status-banners.js";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -609,6 +610,35 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
projection.settle();
|
||||
});
|
||||
|
||||
it("turns Claude status banners into paired provider notices before text projection", () => {
|
||||
const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8");
|
||||
const sanitizer = source.slice(source.indexOf("function sanitizeRuntimeEvent"), source.indexOf("function claudeStatusBannerNotice"));
|
||||
// The banner check runs before the text_delta projection.
|
||||
expect(sanitizer.indexOf("claudeStatusBannerNotice(event)")).toBeGreaterThan(0);
|
||||
expect(sanitizer.indexOf("claudeStatusBannerNotice(event)")).toBeLessThan(sanitizer.indexOf('if (event.type === "text_delta")'));
|
||||
const start = source.indexOf("function claudeStatusBannerNotice");
|
||||
const end = source.indexOf("\nfunction sanitizeRuntimeStatus", start);
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const fn = stripTypeScriptTypes(source.slice(start, end));
|
||||
const make = (agent: string) => new Function("classifyClaudeStatusBanner",
|
||||
`let initializedAgent = ${JSON.stringify(agent)}; let claudeCompactionCount = 0; ${fn} return claudeStatusBannerNotice;`,
|
||||
)(classifyClaudeStatusBanner) as (event: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
const notice = make("claude");
|
||||
const chunk = (text: string, messageId?: string) => ({ type: "text_delta", stream: "output", text, ...(messageId ? { messageId } : {}) });
|
||||
const started = notice(chunk("Compacting..."));
|
||||
const completed = notice(chunk("\n\nCompacting completed."));
|
||||
const next = notice(chunk("Compacting..."));
|
||||
expect(started).toMatchObject({ type: "provider_notice", category: "context_compaction", noticeKey: "compaction-1",
|
||||
details: [{ name: "Phase", value: "running" }] });
|
||||
expect(completed).toMatchObject({ noticeKey: "compaction-1", details: [{ name: "Phase", value: "completed" }] });
|
||||
expect(next).toMatchObject({ noticeKey: "compaction-2" });
|
||||
expect(notice(chunk("**Warning:** Stop hook blocked continuation"))).toMatchObject({
|
||||
type: "provider_notice", category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation",
|
||||
});
|
||||
expect(notice(chunk("Compacting...", "msg_01"))).toBeNull();
|
||||
expect(notice({ ...chunk("Compacting..."), stream: "thought" })).toBeNull();
|
||||
expect(make("codex")(chunk("Compacting..."))).toBeNull();
|
||||
});
|
||||
it("passes only validated Pi native boundaries and history through the real text sanitizer", () => {
|
||||
const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8");
|
||||
const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent"));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { classifyClaudeStatusBanner } from "../drivers/acpx/claude-status-banners.js";
|
||||
import { resolveGrokDisableWebSearch } from "../drivers/acpx/grok-web-search.js";
|
||||
import { toolFileChangesFromAcpContent, workspaceRootsFor, type ToolFileChange } from "../tool-file-changes.js";
|
||||
import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js";
|
||||
@@ -165,6 +166,8 @@ let host: AcpxRuntimeHost | null = null;
|
||||
let activeHostCleanup: Promise<void> | null = null;
|
||||
let failedAdmissionCleanup: Promise<void> | null = null;
|
||||
let openParams: AcpxSidecarOpenParams | null = null;
|
||||
/** Claude compaction banners seen by this sidecar; pairs start and end notices. */
|
||||
let claudeCompactionCount = 0;
|
||||
let runId: string | null = null;
|
||||
let turnId: string | null = null;
|
||||
let activeCopilotEvidence: CopilotToolEvidence | undefined;
|
||||
@@ -1075,6 +1078,8 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent> &
|
||||
entries: sanitizeAcpxPlanEntries(record(event).entries),
|
||||
};
|
||||
}
|
||||
const statusNotice = claudeStatusBannerNotice(event);
|
||||
if (statusNotice) return statusNotice;
|
||||
if (event.type === "text_delta") {
|
||||
return {
|
||||
type: event.stream === "thought" ? "thinking" : "text_delta",
|
||||
@@ -1194,6 +1199,24 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent> &
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude harness lifecycle text becomes a status notice, never part of the answer. */
|
||||
function claudeStatusBannerNotice(event: AcpRuntimeEvent): Record<string, unknown> | null {
|
||||
if (initializedAgent !== "claude" || event.type !== "text_delta" || event.stream === "thought") return null;
|
||||
const banner = classifyClaudeStatusBanner(event.text, typeof event.messageId === "string" && event.messageId ? event.messageId : null);
|
||||
if (!banner) return null;
|
||||
if (banner.phase === "running") claudeCompactionCount += 1;
|
||||
return {
|
||||
type: "provider_notice",
|
||||
severity: banner.severity,
|
||||
category: banner.category,
|
||||
summary: banner.summary,
|
||||
...(banner.category === "context_compaction" ? { noticeKey: `compaction-${claudeCompactionCount}` } : {}),
|
||||
...(banner.phase || banner.details.length > 0
|
||||
? { details: [...(banner.phase ? [{ name: "Phase", value: banner.phase }] : []), ...banner.details] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeRuntimeStatus(value: unknown): Record<string, unknown> {
|
||||
const status = record(value);
|
||||
const models = record(status.models);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyClaudeStatusBanner } from "./claude-status-banners.js";
|
||||
|
||||
describe("classifyClaudeStatusBanner", () => {
|
||||
it("classifies the claude-agent-acp compaction banners", () => {
|
||||
expect(classifyClaudeStatusBanner("Compacting...", null)).toMatchObject({ category: "context_compaction", phase: "running" });
|
||||
expect(classifyClaudeStatusBanner("\n\nCompacting completed.", null)).toMatchObject({ category: "context_compaction", phase: "completed" });
|
||||
expect(classifyClaudeStatusBanner("\n\nCompacting failed: Conversation too long", null)).toMatchObject({
|
||||
category: "context_compaction", phase: "failed", severity: "warning", summary: "Conversation too long",
|
||||
});
|
||||
expect(classifyClaudeStatusBanner("\n\nCompacting failed.", null)?.summary).toBe("Context compaction failed.");
|
||||
});
|
||||
|
||||
it("parses model fallback into the original and fallback models", () => {
|
||||
const banner = classifyClaudeStatusBanner(
|
||||
"**Model fallback:** claude-opus-5-5 declined this request (cyber); retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5.\n\nExplanation.",
|
||||
null,
|
||||
);
|
||||
expect(banner).toMatchObject({
|
||||
category: "model_fallback",
|
||||
details: [
|
||||
{ name: "Original model", value: "claude-opus-5-5" },
|
||||
{ name: "Fallback model", value: "claude-sonnet-5-5" },
|
||||
{ name: "Reason", value: "declined the request (cyber)" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies prominent SDK informational banners", () => {
|
||||
expect(classifyClaudeStatusBanner("**Warning:** Stop hook blocked continuation", null)).toMatchObject({
|
||||
category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation",
|
||||
});
|
||||
expect(classifyClaudeStatusBanner("**Notice:** Using cached credentials", null)?.severity).toBe("info");
|
||||
});
|
||||
|
||||
it("leaves model output alone, including identical text from a streamed message", () => {
|
||||
expect(classifyClaudeStatusBanner("Compacting...", "msg_01")).toBeNull();
|
||||
expect(classifyClaudeStatusBanner("**Warning:** this API is deprecated", "msg_01")).toBeNull();
|
||||
expect(classifyClaudeStatusBanner("Compacting the bundle now.", null)).toBeNull();
|
||||
expect(classifyClaudeStatusBanner("Done.", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* claude-agent-acp 0.73 reports harness lifecycle (compaction, model fallback,
|
||||
* SDK informational notices) as plain `agent_message_chunk` text because ACP
|
||||
* has no status update. Each banner is sent as one complete chunk with no
|
||||
* `messageId`, while model output always carries the streamed message's id.
|
||||
* Both conditions must hold, so a model that writes "Compacting..." itself
|
||||
* stays in the answer.
|
||||
*
|
||||
* Source: `dist/acp-agent.js` `status`, `informational` and
|
||||
* `model_refusal_fallback` handlers.
|
||||
*/
|
||||
|
||||
export interface ClaudeStatusBanner {
|
||||
category: "context_compaction" | "model_fallback" | "provider_informational";
|
||||
severity: "info" | "warning";
|
||||
summary: string;
|
||||
details: Array<{ name: string; value: string }>;
|
||||
/** Compaction start and end update the same notice. */
|
||||
phase?: "running" | "completed" | "failed";
|
||||
}
|
||||
|
||||
const INFORMATIONAL = /^\*\*(Notice|Suggestion|Warning):\*\* ([\s\S]+)$/;
|
||||
const FALLBACK = /^\*\*Model fallback:\*\* ([\s\S]+)$/;
|
||||
const FALLBACK_SUMMARY = /^(\S+) declined this request(?: \(([^)]+)\))?; retried with (\S+?)\./;
|
||||
|
||||
function bounded(value: string): string {
|
||||
return value.trim().slice(0, 4000);
|
||||
}
|
||||
|
||||
export function classifyClaudeStatusBanner(
|
||||
chunk: string,
|
||||
messageId: string | null,
|
||||
): ClaudeStatusBanner | null {
|
||||
if (messageId) return null;
|
||||
const text = chunk.trim();
|
||||
if (text === "Compacting...") {
|
||||
return { category: "context_compaction", severity: "info", summary: "Compacting context", details: [], phase: "running" };
|
||||
}
|
||||
if (text === "Compacting completed.") {
|
||||
return { category: "context_compaction", severity: "info", summary: "Context compacted", details: [], phase: "completed" };
|
||||
}
|
||||
if (text.startsWith("Compacting failed")) {
|
||||
const reason = text.slice("Compacting failed".length).replace(/^[:.]\s*/, "");
|
||||
return {
|
||||
category: "context_compaction",
|
||||
severity: "warning",
|
||||
summary: bounded(reason || "Context compaction failed."),
|
||||
details: [],
|
||||
phase: "failed",
|
||||
};
|
||||
}
|
||||
const fallback = FALLBACK.exec(text);
|
||||
if (fallback) {
|
||||
const summary = bounded(fallback[1]);
|
||||
const parsed = FALLBACK_SUMMARY.exec(summary);
|
||||
const details = parsed
|
||||
? [
|
||||
{ name: "Original model", value: parsed[1].slice(0, 160) },
|
||||
{ name: "Fallback model", value: parsed[3].slice(0, 160) },
|
||||
{ name: "Reason", value: (parsed[2] ? `declined the request (${parsed[2]})` : "declined the request").slice(0, 4000) },
|
||||
]
|
||||
: [];
|
||||
return { category: "model_fallback", severity: "warning", summary, details };
|
||||
}
|
||||
const informational = INFORMATIONAL.exec(text);
|
||||
if (informational) {
|
||||
return {
|
||||
category: "provider_informational",
|
||||
severity: informational[1] === "Warning" ? "warning" : "info",
|
||||
summary: bounded(informational[2]),
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -442,6 +442,34 @@ describe("Codex ACPX harness driver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Claude status banners out of the answer as status notices", async () => {
|
||||
const fixture = driverFixture({ agent: "claude", model: "claude-sonnet-5" }, {
|
||||
runtimeEvents: [
|
||||
{ type: "text_delta", stream: "output", messageId: "m1", text: "Reading the worker." },
|
||||
{ type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "Compacting..." },
|
||||
{ type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "\n\nCompacting completed." },
|
||||
{ type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "**Warning:** Stop hook blocked continuation" },
|
||||
{ type: "text_delta", stream: "output", messageId: "m1", text: " Fixed it." },
|
||||
],
|
||||
});
|
||||
const session = await fixture.driver.openSession({ runId: "run-claude-banners", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
await session.startTurn({ message: { text: "Fix it" } });
|
||||
fixture.finishTurn({ status: "completed" });
|
||||
const events = await collectUntil(session.events(), "turn.completed");
|
||||
for (const event of events) validatePrpEvent(event);
|
||||
const notices = events.filter(e => e.eventType === "provider.notice.recorded").map(e => e.payload);
|
||||
expect(notices).toEqual([
|
||||
expect.objectContaining({ category: "context_compaction", details: [{ name: "Phase", value: "running" }] }),
|
||||
expect.objectContaining({ category: "context_compaction", details: [{ name: "Phase", value: "completed" }] }),
|
||||
expect.objectContaining({ category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation" }),
|
||||
]);
|
||||
expect(notices[0].noticeId).toBe(notices[1].noticeId);
|
||||
const final = events.find(e => e.eventType === "item.completed" && e.payload.channel === "final");
|
||||
expect(final?.payload.text).toBe("Reading the worker. Fixed it.");
|
||||
expect(events.filter(e => e.eventType === "item.delta").map(e => e.payload.text).join("")).toBe("Reading the worker. Fixed it.");
|
||||
await session.close({ reason: "banners classified" });
|
||||
});
|
||||
|
||||
it("opens the qualified Claude profile through the shared driver", async () => {
|
||||
const fixture = driverFixture({
|
||||
agent: "claude",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CopilotToolEvidence } from "./copilot-tool-evidence.js";
|
||||
import { classifyClaudeStatusBanner } from "./claude-status-banners.js";
|
||||
import { resolveCompletionContinuation } from "../completion-continuation.js";
|
||||
import { isProviderMode } from "../../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "./profile-activity.js";
|
||||
@@ -782,6 +783,8 @@ class CodexAcpxSession implements HarnessSession {
|
||||
} | null = null;
|
||||
#usage: Record<string, unknown> | null = null;
|
||||
#assistantText = "";
|
||||
/** Claude compaction banners in this session; pairs start and end notices. */
|
||||
#compactionCount = 0;
|
||||
#assistantMessageId: string | null = null;
|
||||
#workspaceRoots: string[] | null = null;
|
||||
#closed = false;
|
||||
@@ -1695,6 +1698,34 @@ class CodexAcpxSession implements HarnessSession {
|
||||
const isReasoning =
|
||||
event.stream === "thought" || event.tag === "agent_thought_chunk";
|
||||
const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null;
|
||||
const banner = this.#agent === "claude" && !isReasoning
|
||||
? classifyClaudeStatusBanner(output, messageId)
|
||||
: null;
|
||||
if (banner) {
|
||||
// Harness lifecycle text is a status notice, never part of the answer.
|
||||
if (banner.phase === "running") this.#compactionCount += 1;
|
||||
const noticeId = banner.category === "context_compaction"
|
||||
? `${turnId}:compaction:${this.#compactionCount}`
|
||||
: `${turnId}:status:${index}`;
|
||||
this.#emit(
|
||||
"provider.notice.recorded",
|
||||
{
|
||||
schema: "paperclip.provider.notice.v1",
|
||||
noticeId,
|
||||
severity: banner.severity,
|
||||
category: banner.category,
|
||||
scope: banner.category === "model_fallback" ? "session" : "turn",
|
||||
recoverable: true,
|
||||
userActionable: false,
|
||||
summary: banner.summary,
|
||||
...(banner.phase || banner.details.length > 0
|
||||
? { details: [...(banner.phase ? [{ name: "Phase", value: banner.phase }] : []), ...banner.details] }
|
||||
: {}),
|
||||
},
|
||||
{ turnId, itemId: noticeId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
let kimiCommentary: { text: string; itemId: string } | undefined;
|
||||
// The pinned Copilot mapper supplies actual native identity, including
|
||||
// empty starts. Unidentified session info/warnings remain activity only.
|
||||
|
||||
@@ -281,7 +281,10 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
|
||||
state.emit("harness.diagnostic", {
|
||||
code: notification.method.replaceAll("/", "_"),
|
||||
message: redactCodexDiagnostic(
|
||||
text(params.message, JSON.stringify(boundedCodexValue(params))),
|
||||
text(
|
||||
record(params.error).message,
|
||||
text(params.message, JSON.stringify(boundedCodexValue(params))),
|
||||
),
|
||||
),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -77,6 +77,47 @@ describe("provider-neutral events", () => {
|
||||
})[0].eventType).toBe("provider.notice.recorded");
|
||||
});
|
||||
|
||||
it("maps Codex TurnError text, retries and error info into the notice", () => {
|
||||
const [retry] = canonicalProviderEventsFromCodex("error", {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
willRetry: true,
|
||||
error: {
|
||||
message: "Reconnecting... 2/5",
|
||||
codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } },
|
||||
additionalDetails: "stream closed before response.completed",
|
||||
},
|
||||
});
|
||||
expect(retry.payload).toMatchObject({
|
||||
severity: "warning",
|
||||
category: "provider_retry",
|
||||
recoverable: true,
|
||||
summary: "stream closed before response.completed",
|
||||
details: [
|
||||
{ name: "Attempt", value: "2" },
|
||||
{ name: "Max attempts", value: "5" },
|
||||
{ name: "Reason", value: "responseStreamDisconnected" },
|
||||
{ name: "HTTP status", value: "502" },
|
||||
],
|
||||
});
|
||||
expect(validatePrpEvent(envelope(retry)).ok).toBe(true);
|
||||
|
||||
const [fatal] = canonicalProviderEventsFromCodex("error", {
|
||||
turnId: "turn-1",
|
||||
willRetry: false,
|
||||
error: { message: "You've hit your usage limit.", codexErrorInfo: "usageLimitExceeded", additionalDetails: null },
|
||||
});
|
||||
expect(fatal.payload).toMatchObject({
|
||||
severity: "error",
|
||||
category: "error",
|
||||
recoverable: false,
|
||||
summary: "You've hit your usage limit.",
|
||||
details: [{ name: "Reason", value: "usageLimitExceeded" }],
|
||||
});
|
||||
expect(fatal.payload.noticeId).not.toBe(retry.payload.noticeId);
|
||||
expect(validatePrpEvent(envelope(fatal)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves Codex notice summaries with legacy and empty-message fallbacks", () => {
|
||||
for (const method of ["configWarning", "deprecationNotice", "warning"]) {
|
||||
for (const [params, expected] of [
|
||||
|
||||
@@ -1127,9 +1127,9 @@ export function canonicalProviderEventsFromCodex(
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "error") return [codexErrorNotice(params, itemId)];
|
||||
if (
|
||||
[
|
||||
"error",
|
||||
"warning",
|
||||
"guardianWarning",
|
||||
"deprecationNotice",
|
||||
@@ -1168,6 +1168,60 @@ export function canonicalProviderEventsFromCodex(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex app-server `error` notifications carry a `TurnError` under `error`
|
||||
* plus `willRetry`. A retried error is a transient status notice; only a
|
||||
* final error is fatal. Older shapes kept the text at the top level.
|
||||
*/
|
||||
function codexErrorNotice(
|
||||
params: Record<string, unknown>,
|
||||
itemId: string,
|
||||
): CanonicalProviderEvent {
|
||||
const error = record(params.error);
|
||||
const willRetry = params.willRetry === true;
|
||||
const info = error.codexErrorInfo;
|
||||
const infoKind =
|
||||
typeof info === "string"
|
||||
? info
|
||||
: Object.keys(record(info))[0] ?? "";
|
||||
const httpStatus = integer(record(record(info)[infoKind]).httpStatusCode);
|
||||
const summary = [error.message, params.summary, params.message, params.details]
|
||||
.map((value) => redactCodexDiagnostic(text(value).trim()))
|
||||
.find(Boolean)
|
||||
?.slice(0, 4000);
|
||||
// Codex reports stream retries as "Reconnecting... {attempt}/{max}" with
|
||||
// the actual cause in additionalDetails.
|
||||
const attempt = /^Reconnecting\.\.\.\s*(\d+)\s*\/\s*(\d+)$/.exec(summary ?? "");
|
||||
const additional = redactCodexDiagnostic(text(error.additionalDetails).trim()).slice(0, 4000);
|
||||
const chipSummary = attempt && additional ? additional : summary;
|
||||
const details = [
|
||||
...(attempt ? [{ name: "Attempt", value: attempt[1] }, { name: "Max attempts", value: attempt[2] }] : []),
|
||||
...(infoKind ? [{ name: "Reason", value: infoKind.slice(0, 160) }] : []),
|
||||
...(httpStatus !== null ? [{ name: "HTTP status", value: String(httpStatus) }] : []),
|
||||
...(additional && additional !== chipSummary ? [{ name: "Additional details", value: additional }] : []),
|
||||
];
|
||||
const turnId = text(params.turnId);
|
||||
return {
|
||||
eventType: "provider.notice.recorded",
|
||||
payload: {
|
||||
schema: "paperclip.provider.notice.v1",
|
||||
// Retries within one turn update one chip; the final error is its own row.
|
||||
noticeId: safeId(
|
||||
`error:${willRetry ? "retry" : "final"}:${turnId || text(params.code, "notice")}`,
|
||||
"provider-notice",
|
||||
),
|
||||
severity: willRetry ? "warning" : "error",
|
||||
category: willRetry ? "provider_retry" : "error",
|
||||
scope: "turn",
|
||||
recoverable: willRetry,
|
||||
userActionable: false,
|
||||
summary: chipSummary || "Provider notice",
|
||||
...(details.length > 0 ? { details } : {}),
|
||||
},
|
||||
itemId,
|
||||
};
|
||||
}
|
||||
|
||||
function openCodeToolExitCode(
|
||||
part: Record<string, unknown>,
|
||||
state: Record<string, unknown>,
|
||||
|
||||
@@ -47,7 +47,7 @@ function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false)
|
||||
return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
|
||||
}
|
||||
|
||||
function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) {
|
||||
export function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) {
|
||||
if (details.length === 0) return null;
|
||||
return (
|
||||
<dl className="flex min-w-0 flex-col gap-1.5" data-testid="task-chat-provider-detail-list">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { TaskChatStatusNotice } from "./TaskChatStatusNotice";
|
||||
import { isStatusNotice } from "./status-notice-presentation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
@@ -1119,7 +1121,9 @@ export function TaskChatProtocolCard({
|
||||
}: TaskChatProtocolCardProps) {
|
||||
switch (item.surface) {
|
||||
case "provider_activity":
|
||||
return <ProviderActivityCard item={item} />;
|
||||
return isStatusNotice(item)
|
||||
? <TaskChatStatusNotice item={item} resolved={item.resolved} />
|
||||
: <ProviderActivityCard item={item} />;
|
||||
case "workspace_change":
|
||||
return <WorkspaceChangeCard item={item} />;
|
||||
case "workspace_file":
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import { TaskChatStatusNotice } from "./TaskChatStatusNotice";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function notice(details: Record<string, string>, summary?: string): TaskChatProviderActivityItem {
|
||||
return {
|
||||
id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice",
|
||||
eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary,
|
||||
details: Object.entries(details).map(([label, value]) => ({ label, value })),
|
||||
steps: [], links: [], children: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskChatStatusNotice", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("labels a Codex retry by its error info and fades it once resolved", () => {
|
||||
const item = notice({ Severity: "warning", Category: "provider_retry", Recoverable: "true", Reason: "serverOverloaded" }, "High demand");
|
||||
act(() => root.render(<TaskChatStatusNotice item={item} />));
|
||||
expect(container.textContent).toContain("Retrying after overload");
|
||||
expect(container.textContent).toContain("High demand");
|
||||
act(() => root.render(<TaskChatStatusNotice item={item} resolved />));
|
||||
expect(container.textContent).toContain("Recovered after retry");
|
||||
expect(container.querySelector("[data-status-notice-resolved=true]")).not.toBeNull();
|
||||
act(() => root.render(<TaskChatStatusNotice item={{ ...item, status: "interrupted" }} />));
|
||||
expect(container.textContent).toContain("Retry stopped");
|
||||
});
|
||||
|
||||
it("shows compaction phases and a model fallback target", () => {
|
||||
act(() => root.render(<TaskChatStatusNotice item={notice({ Severity: "info", Category: "context_compaction", Phase: "running" })} />));
|
||||
expect(container.textContent).toContain("Compacting context…");
|
||||
act(() => root.render(<TaskChatStatusNotice item={{ ...notice({ Severity: "info", Category: "context_compaction", Phase: "running" }), status: "interrupted" }} />));
|
||||
expect(container.textContent).toContain("Compaction stopped");
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
act(() => root.render(<TaskChatStatusNotice item={notice({ Severity: "warning", Category: "model_fallback", "Fallback model": "claude-sonnet-5-5", Reason: "declined the request" })} />));
|
||||
expect(container.textContent).toContain("Fell back to claude-sonnet-5-5");
|
||||
});
|
||||
|
||||
it("shows the full fallback explanation when the chip is expanded", () => {
|
||||
const full = "claude-opus-5-5 declined this request (cyber); retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5.";
|
||||
act(() => root.render(<TaskChatStatusNotice item={notice({ Severity: "warning", Category: "model_fallback", "Fallback model": "claude-sonnet-5-5", Reason: "declined the request (cyber)" }, full)} />));
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
act(() => button!.click());
|
||||
expect(container.textContent).toContain(full);
|
||||
});
|
||||
|
||||
it("does not treat a recoverable error notice from the live adapter as fatal", () => {
|
||||
act(() => root.render(<TaskChatStatusNotice item={notice({ Severity: "error", Category: "error", Recoverable: "Yes" }, "transient")} />));
|
||||
expect(container.querySelector("[role=alert]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a fatal provider error as an alert with the real message", () => {
|
||||
act(() => root.render(<TaskChatStatusNotice item={notice({ Severity: "error", Category: "error", Recoverable: "No", Reason: "usageLimitExceeded" }, "You've hit your usage limit.")} />));
|
||||
const alert = container.querySelector("[role=alert]");
|
||||
expect(alert?.textContent).toContain("Provider error");
|
||||
expect(alert?.textContent).toContain("You've hit your usage limit.");
|
||||
expect(alert?.textContent).toContain("usageLimitExceeded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import { statusNoticePresentation, type StatusNoticeTone } from "./status-notice-presentation";
|
||||
import { DetailList } from "./TaskChatProtocolActivityRow";
|
||||
|
||||
/** Payload keys already reflected by the chip itself. */
|
||||
const HIDDEN_DETAILS = new Set(["Severity", "Category", "Scope", "Recoverable", "User Actionable", "Summary", "Phase"]);
|
||||
|
||||
const TONE_CLASS: Record<StatusNoticeTone, string> = {
|
||||
neutral: "border-border text-muted-foreground",
|
||||
progress: "border-border text-muted-foreground",
|
||||
warning: "border-(--tc-status-notice-warning-border) bg-(--tc-status-notice-warning-bg) text-(--tc-status-notice-warning-fg)",
|
||||
danger: "border-destructive/35 bg-destructive/5 text-destructive",
|
||||
};
|
||||
|
||||
/**
|
||||
* An inline harness status chip between the turn's activity rows: context
|
||||
* compaction, retries, model fallback and provider banners. A fatal provider
|
||||
* error renders as a red row with the provider's real message instead.
|
||||
*/
|
||||
export function TaskChatStatusNotice({
|
||||
item,
|
||||
resolved = false,
|
||||
}: {
|
||||
item: TaskChatProviderActivityItem;
|
||||
/** A retry the turn moved past: the chip fades to a quiet receipt. */
|
||||
resolved?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailId = `task-chat-status-notice-${useId().replaceAll(":", "")}`;
|
||||
const presentation = statusNoticePresentation(item, { resolved });
|
||||
if (!presentation) return null;
|
||||
const Icon = presentation.icon;
|
||||
const extra = item.details.filter((entry) => !HIDDEN_DETAILS.has(entry.label) && entry.value !== presentation.detail);
|
||||
// The chip may show a short reason; keep the provider's full message on expand.
|
||||
const fullSummary = item.summary?.trim() || item.details.find((entry) => entry.label === "Summary")?.value;
|
||||
const expandedText = fullSummary && fullSummary !== presentation.detail ? fullSummary : presentation.detail;
|
||||
|
||||
if (presentation.fatal) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-w-0 flex-col gap-1 rounded-md border border-destructive/35 bg-destructive/5 px-3 py-2 text-xs"
|
||||
data-testid="task-chat-status-notice"
|
||||
data-status-notice-category={presentation.category}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-destructive">
|
||||
<Icon className="size-(--sz-protocol-notice-icon) shrink-0" aria-hidden />
|
||||
<span>{presentation.label}</span>
|
||||
</div>
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{presentation.detail}</p>
|
||||
<DetailList details={extra} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Any message can be truncated on a narrow screen, so it stays reachable.
|
||||
const expandable = extra.length > 0 || Boolean(presentation.detail) || expandedText !== presentation.detail;
|
||||
const chip = (
|
||||
<>
|
||||
<Icon
|
||||
className={cn("size-(--sz-protocol-notice-icon) shrink-0", presentation.active && presentation.tone === "progress" && "animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={cn("shrink-0 font-medium", presentation.active && presentation.tone === "progress" && "shimmer-text shimmer-text-muted")}>
|
||||
{presentation.label}{presentation.active && presentation.tone === "progress" ? "…" : ""}
|
||||
</span>
|
||||
{presentation.detail ? (
|
||||
<span className="min-w-0 truncate opacity-80">{presentation.detail}</span>
|
||||
) : null}
|
||||
{expandable ? (
|
||||
<ChevronRight className={cn("size-3 shrink-0 transition-transform", open && "rotate-90")} aria-hidden />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const chipClass = cn(
|
||||
"inline-flex min-h-6 max-w-full min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs transition-opacity duration-(--motion-status-notice-fade)",
|
||||
TONE_CLASS[presentation.tone],
|
||||
resolved && "opacity-60",
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col items-start gap-1"
|
||||
role="status"
|
||||
data-testid="task-chat-status-notice"
|
||||
data-status-notice-category={presentation.category}
|
||||
data-status-notice-resolved={resolved ? "true" : undefined}
|
||||
>
|
||||
{expandable ? (
|
||||
<button type="button" className={cn(chipClass, "hover:opacity-100")} aria-expanded={open} aria-controls={detailId} onClick={() => setOpen((value) => !value)}>
|
||||
{chip}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chipClass}>{chip}</span>
|
||||
)}
|
||||
{expandable && open ? (
|
||||
<div id={detailId} className="ml-3 flex min-w-0 flex-col gap-1 border-l border-border pl-3 text-xs text-muted-foreground">
|
||||
{expandedText ? <p className="whitespace-pre-wrap break-words text-foreground">{expandedText}</p> : null}
|
||||
<DetailList details={extra} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-diff-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-status-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-status-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-status-notice-fade", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-approval-pulse", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
|
||||
{ name: "--motion-plan-entry-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
|
||||
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlertCircle, AlertTriangle, ArrowRightLeft, Check, Info, Layers, Loader2, RotateCw, TerminalSquare } from "lucide-react";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import type { ToolIcon } from "./tool-taxonomy";
|
||||
|
||||
/**
|
||||
* Provider status notices are harness lifecycle reports (context compaction,
|
||||
* retries, model fallback, informational banners). They render as inline
|
||||
* chips between the turn's activity and never as part of the answer.
|
||||
*
|
||||
* The runner sets the category; the UI never classifies by message text.
|
||||
*/
|
||||
export const STATUS_NOTICE_CATEGORIES = [
|
||||
"context_compaction",
|
||||
"provider_retry",
|
||||
"model_fallback",
|
||||
"provider_informational",
|
||||
"local_command_output",
|
||||
] as const;
|
||||
export type StatusNoticeCategory = (typeof STATUS_NOTICE_CATEGORIES)[number];
|
||||
|
||||
export type StatusNoticeTone = "neutral" | "progress" | "warning" | "danger";
|
||||
|
||||
export interface StatusNoticePresentation {
|
||||
category: StatusNoticeCategory | "provider_error";
|
||||
icon: ToolIcon;
|
||||
tone: StatusNoticeTone;
|
||||
/** The short chip label, such as "Compacting context". */
|
||||
label: string;
|
||||
/** Provider text shown after the label, such as the retry reason. */
|
||||
detail?: string;
|
||||
/** True while the notice describes something still happening. */
|
||||
active: boolean;
|
||||
/** A fatal provider error renders as a full row, not a chip. */
|
||||
fatal: boolean;
|
||||
}
|
||||
|
||||
function detail(item: TaskChatProviderActivityItem, label: string): string | undefined {
|
||||
return item.details.find((entry) => entry.label === label)?.value;
|
||||
}
|
||||
|
||||
export function statusNoticeCategory(item: TaskChatProviderActivityItem): StatusNoticeCategory | null {
|
||||
if (item.family !== "provider_notice") return null;
|
||||
const category = detail(item, "Category");
|
||||
return (STATUS_NOTICE_CATEGORIES as readonly string[]).includes(category ?? "")
|
||||
? category as StatusNoticeCategory
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A turn-scoped provider error that will not be retried. */
|
||||
export function isFatalProviderError(item: TaskChatProviderActivityItem): boolean {
|
||||
return item.family === "provider_notice"
|
||||
&& detail(item, "Category") === "error"
|
||||
&& detail(item, "Severity") === "error"
|
||||
&& !["true", "Yes"].includes(detail(item, "Recoverable") ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical notice payload a status chip renders: a runner-classified
|
||||
* status category, or a turn-scoped provider error that will not be retried.
|
||||
* Generic Codex warnings keep their run-log-only treatment.
|
||||
*/
|
||||
export function isStatusNoticePayload(payload: Record<string, unknown>): boolean {
|
||||
if ((STATUS_NOTICE_CATEGORIES as readonly string[]).includes(String(payload.category))) return true;
|
||||
return payload.category === "error" && payload.severity === "error" && payload.recoverable !== true && payload.scope === "turn";
|
||||
}
|
||||
|
||||
export function isStatusNotice(item: TaskChatProviderActivityItem): boolean {
|
||||
return statusNoticeCategory(item) !== null || isFatalProviderError(item);
|
||||
}
|
||||
|
||||
function summary(item: TaskChatProviderActivityItem): string | undefined {
|
||||
return item.summary?.trim() || detail(item, "Summary")?.trim() || undefined;
|
||||
}
|
||||
|
||||
function retryLabel(item: TaskChatProviderActivityItem): string {
|
||||
switch (detail(item, "Reason")) {
|
||||
case "serverOverloaded": return "Retrying after overload";
|
||||
case "responseStreamDisconnected":
|
||||
case "responseStreamConnectionFailed":
|
||||
case "transport_lost": return "Reconnecting";
|
||||
case "httpConnectionFailed": return "Retrying connection";
|
||||
case "rate_limit": return "Retrying after rate limit";
|
||||
default: return "Retrying";
|
||||
}
|
||||
}
|
||||
|
||||
function attemptSuffix(item: TaskChatProviderActivityItem): string {
|
||||
const attempt = detail(item, "Attempt");
|
||||
const max = detail(item, "Max attempts");
|
||||
if (!attempt) return "";
|
||||
return max ? ` (attempt ${attempt} of ${max})` : ` (attempt ${attempt})`;
|
||||
}
|
||||
|
||||
export function statusNoticePresentation(
|
||||
item: TaskChatProviderActivityItem,
|
||||
options: { resolved?: boolean } = {},
|
||||
): StatusNoticePresentation | null {
|
||||
if (isFatalProviderError(item)) {
|
||||
return {
|
||||
category: "provider_error",
|
||||
icon: AlertCircle,
|
||||
tone: "danger",
|
||||
label: "Provider error",
|
||||
detail: summary(item) ?? "The provider reported an error without a message.",
|
||||
active: false,
|
||||
fatal: true,
|
||||
};
|
||||
}
|
||||
const category = statusNoticeCategory(item);
|
||||
if (!category) return null;
|
||||
const phase = detail(item, "Phase");
|
||||
switch (category) {
|
||||
case "context_compaction":
|
||||
if (phase === "failed") {
|
||||
return { category, icon: AlertTriangle, tone: "warning", label: "Context compaction failed", detail: summary(item), active: false, fatal: false };
|
||||
}
|
||||
if (phase === "running" && item.status === "interrupted") {
|
||||
return { category, icon: Layers, tone: "neutral", label: "Compaction stopped", active: false, fatal: false };
|
||||
}
|
||||
return phase === "running"
|
||||
? { category, icon: Loader2, tone: "progress", label: "Compacting context", active: true, fatal: false }
|
||||
: { category, icon: Layers, tone: "neutral", label: "Context compacted", active: false, fatal: false };
|
||||
case "provider_retry":
|
||||
if (!options.resolved && item.status === "interrupted") {
|
||||
return { category, icon: AlertTriangle, tone: "neutral", label: "Retry stopped", detail: summary(item), active: false, fatal: false };
|
||||
}
|
||||
return options.resolved
|
||||
? { category, icon: Check, tone: "neutral", label: "Recovered after retry", detail: summary(item), active: false, fatal: false }
|
||||
: { category, icon: RotateCw, tone: "warning", label: `${retryLabel(item)}${attemptSuffix(item)}`, detail: summary(item), active: true, fatal: false };
|
||||
case "model_fallback": {
|
||||
const model = detail(item, "Fallback model");
|
||||
const reason = detail(item, "Reason");
|
||||
return {
|
||||
category,
|
||||
icon: ArrowRightLeft,
|
||||
tone: "warning",
|
||||
label: model ? `Fell back to ${model}` : "Fell back to another model",
|
||||
detail: reason ?? summary(item),
|
||||
active: false,
|
||||
fatal: false,
|
||||
};
|
||||
}
|
||||
case "provider_informational": {
|
||||
const severity = detail(item, "Severity");
|
||||
return {
|
||||
category,
|
||||
icon: severity === "info" ? Info : AlertTriangle,
|
||||
tone: severity === "error" ? "danger" : severity === "warning" ? "warning" : "neutral",
|
||||
label: severity === "error" ? "Provider error" : severity === "warning" ? "Provider warning" : "Provider update",
|
||||
detail: summary(item),
|
||||
active: false,
|
||||
fatal: false,
|
||||
};
|
||||
}
|
||||
case "local_command_output":
|
||||
return { category, icon: TerminalSquare, tone: "neutral", label: "Command output", detail: summary(item), active: false, fatal: false };
|
||||
}
|
||||
}
|
||||
@@ -130,3 +130,14 @@ it("removes run lifecycle diagnostics while retaining tool failures and pending
|
||||
expect(withoutHistoricalRunStatus(items)).toEqual(retained);
|
||||
expect(items).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("keeps harness status chips in historical turns while dropping generic notices", () => {
|
||||
const notice = (id: string, category: string): TaskChatItem => ({
|
||||
id, kind: "protocol", surface: "provider_activity", family: "provider_notice", title: "Provider notice",
|
||||
status: "informational", eventType: "provider.notice.recorded",
|
||||
details: [{ label: "Severity", value: "info" }, { label: "Category", value: category }],
|
||||
steps: [], links: [], children: [],
|
||||
});
|
||||
const kept = notice("compaction", "context_compaction");
|
||||
expect(withoutHistoricalRunStatus([kept, notice("generic", "warning")])).toEqual([kept]);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { ExecutionProjection, IssueRecoveryAction } from "@paperclipai/shar
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import { isTerminalIssueStatus } from "@/lib/liveIssueIds";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
import { isStatusNotice } from "./status-notice-presentation";
|
||||
|
||||
interface StatusRun {
|
||||
id: string;
|
||||
@@ -96,6 +97,9 @@ export function withoutHistoricalRunStatus(items: readonly TaskChatItem[]): Task
|
||||
if (item.kind === "message") return item.author !== "system";
|
||||
if (item.kind !== "protocol") return true;
|
||||
if (item.surface === "run_terminal" || item.surface === "run_result") return false;
|
||||
// Harness status chips (compaction, fallback, retries, a fatal provider
|
||||
// error) explain what happened in that turn, so they stay in history.
|
||||
if (item.surface === "provider_activity" && isStatusNotice(item)) return true;
|
||||
return item.surface !== "provider_activity"
|
||||
|| !["terminal", "wait", "provider_notice"].includes(item.family);
|
||||
});
|
||||
|
||||
@@ -398,6 +398,8 @@ export interface TaskChatProviderActivityItem {
|
||||
outputTruncated?: boolean;
|
||||
/** Index of the latest provider event coalesced into this activity. */
|
||||
transcriptIndex?: number;
|
||||
/** A retry status notice the turn has moved past. */
|
||||
resolved?: boolean;
|
||||
/** When the provider first reported this activity (ISO timestamp). */
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
@@ -2867,6 +2867,131 @@ describe("prependIssueBrief (PAP-375)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("harness status notices", () => {
|
||||
const notice = (noticeId: string, payload: Record<string, unknown>): TranscriptEntry => ({
|
||||
kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
|
||||
status: payload.severity === "error" ? "failed" : "informational", title: "Provider notice",
|
||||
summary: String(payload.summary ?? ""),
|
||||
payload: { schema: "paperclip.provider.notice.v1", noticeId, scope: "turn", userActionable: false, ...payload },
|
||||
});
|
||||
const read: TranscriptEntry = { kind: "tool_call", ts: TS, toolUseId: "read-1", name: "read_file", input: { path: "a.ts" } };
|
||||
const retry = notice("error:retry:turn-1", {
|
||||
severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 2/5",
|
||||
details: [{ name: "Reason", value: "responseStreamDisconnected" }],
|
||||
});
|
||||
|
||||
it("shows status notices as top-level timeline rows while generic warnings stay hidden", () => {
|
||||
const parsed = transcriptToTaskChatItems([
|
||||
read,
|
||||
notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] }),
|
||||
notice("warning-1", { severity: "warning", category: "warning", recoverable: true, summary: "noise" }),
|
||||
], { runId: "status", running: true });
|
||||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), true);
|
||||
expect(rows.map((row) => row.kind === "protocol" && row.surface === "provider_activity" ? row.id : row.kind)).toHaveLength(2);
|
||||
const chip = rows[1];
|
||||
expect(chip.kind === "protocol" && chip.surface === "provider_activity" && chip.details.find((d) => d.label === "Phase")?.value).toBe("running");
|
||||
expect(JSON.stringify(rows)).not.toContain("noise");
|
||||
});
|
||||
|
||||
it("marks a retry resolved once real work follows, and drops it behind a fatal error", () => {
|
||||
const live = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: true })), true);
|
||||
expect(live.at(-1)).toMatchObject({ kind: "protocol" });
|
||||
expect(live.at(-1)).not.toHaveProperty("resolved");
|
||||
|
||||
const recovered = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, { ...read, toolUseId: "read-2" }], { runId: "r", running: true })), true);
|
||||
expect(recovered.find((row) => row.kind === "protocol")).toMatchObject({ resolved: true });
|
||||
|
||||
const failed = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([
|
||||
read, retry,
|
||||
notice("error:final:turn-1", { severity: "error", category: "error", recoverable: false, summary: "You've hit your usage limit." }),
|
||||
], { runId: "r", running: false })), false);
|
||||
const protocols = failed.filter((row) => row.kind === "protocol");
|
||||
expect(protocols).toHaveLength(1);
|
||||
expect(protocols[0]).toMatchObject({ summary: "You've hit your usage limit." });
|
||||
});
|
||||
|
||||
it("keeps a later retry active after work between retries, and collapses back-to-back retries", () => {
|
||||
const retry2 = notice("error:retry:turn-1", {
|
||||
severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 1/5",
|
||||
});
|
||||
const work: TranscriptEntry = { ...read, toolUseId: "read-2" };
|
||||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(
|
||||
[read, retry, work, retry2], { runId: "r", running: true })), true)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ resolved: true });
|
||||
expect(rows[1]).not.toHaveProperty("resolved");
|
||||
const backToBack = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(
|
||||
[read, retry, retry2], { runId: "r", running: true })), true)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(backToBack).toHaveLength(1);
|
||||
expect(backToBack[0]).toMatchObject({ summary: "Reconnecting... 1/5" });
|
||||
});
|
||||
|
||||
it("marks a compaction that never completed as interrupted once the turn ends", () => {
|
||||
const running = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] });
|
||||
const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, running], { runId: "r", running: live })), live)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows(true)[0]).toMatchObject({ status: "informational" });
|
||||
expect(rows(false)[0]).toMatchObject({ status: "interrupted" });
|
||||
});
|
||||
|
||||
it("marks a retry left unresolved by a stopped turn as interrupted", () => {
|
||||
const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: live })), live)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows(true)[0]).toMatchObject({ status: "informational" });
|
||||
expect(rows(false)[0]).toMatchObject({ status: "interrupted" });
|
||||
expect(rows(false)[0]).not.toHaveProperty("resolved");
|
||||
});
|
||||
|
||||
it("treats a retry followed only by the final answer as recovered", () => {
|
||||
const answer: TranscriptEntry = { kind: "assistant", ts: TS, text: "Done: 8 lines.", channel: "final" } as TranscriptEntry;
|
||||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, answer], { runId: "r", running: false })), false)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ resolved: true });
|
||||
expect(rows[0]).not.toMatchObject({ status: "interrupted" });
|
||||
});
|
||||
|
||||
it("keeps a retry stopped when only run-end records follow it", () => {
|
||||
const parsed = [
|
||||
...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }),
|
||||
{ id: "terminal", kind: "protocol", surface: "run_terminal", turnState: "cancelled", runState: "cancelled", disposition: "blocked" } as TaskChatItem,
|
||||
{ id: "stopped", kind: "marker", variant: "interrupted", label: "Run stopped" } as TaskChatItem,
|
||||
];
|
||||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows[0]).toMatchObject({ status: "interrupted" });
|
||||
expect(rows[0]).not.toHaveProperty("resolved");
|
||||
});
|
||||
|
||||
it("does not treat a turn-end changed-files card as recovery from a stopped retry", () => {
|
||||
const parsed = [
|
||||
...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }),
|
||||
{ id: "changes", kind: "protocol", surface: "workspace_change", changeSetId: "c", revision: 1, source: "runner_verified",
|
||||
complete: true, files: [], totals: { files: 1, additions: 2, deletions: 0 }, patchArtifactRef: null } as TaskChatItem,
|
||||
];
|
||||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(rows[0]).toMatchObject({ status: "interrupted" });
|
||||
expect(rows[0]).not.toHaveProperty("resolved");
|
||||
});
|
||||
|
||||
it("lets a context.compacted divider replace the completed compaction chip", () => {
|
||||
const compacted = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Context compacted", details: [{ name: "Phase", value: "completed" }] });
|
||||
const divider: TranscriptEntry = { kind: "provider_activity", ts: TS, family: "context", eventType: "context.compacted", status: "completed", title: "Context", summary: "", payload: { compactionId: "c1", reason: "provider" } };
|
||||
const rows = (entries: TranscriptEntry[]) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(entries, { runId: "r", running: true })), true)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity" && row.family === "provider_notice");
|
||||
expect(rows([read, compacted])).toHaveLength(1);
|
||||
expect(rows([read, compacted, divider])).toHaveLength(0);
|
||||
const marker = { id: "m", kind: "marker", variant: "context_compacted", label: "Context compacted" } as unknown as TaskChatItem;
|
||||
const parsed = paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, compacted], { runId: "r", running: true }));
|
||||
const withMarker = buildTurnTimelineRows([...parsed, marker], true)
|
||||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||||
expect(withMarker).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn changes assembled from edit rows", () => {
|
||||
it("lists each edited file when the harness reports only per-edit patches", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* thinking → tool → diff → responding while a run is in flight, rather than
|
||||
* only showing a response after the turn settles.
|
||||
*/
|
||||
import { isFatalProviderError, isStatusNotice, isStatusNoticePayload, statusNoticeCategory } from "./status-notice-presentation";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { turnChangesFromEditRows } from "./turn-file-changes";
|
||||
import type {
|
||||
@@ -910,10 +911,15 @@ export function transcriptToTaskChatItems(
|
||||
// Use producer classification, including the legacy generic Codex
|
||||
// categories, never message-text matching. The run transcript still
|
||||
// retains these diagnostics.
|
||||
if (entry.family === "provider_notice" && (entry.payload.userActionable !== true ||
|
||||
if (entry.family === "provider_notice" && !isStatusNoticePayload(entry.payload) &&
|
||||
(entry.payload.userActionable !== true ||
|
||||
["error", "warning", "guardianWarning", "configWarning", "deprecationNotice", "windows_worldWritableWarning"]
|
||||
.includes(String(entry.payload.category)))) break;
|
||||
const key = `provider:${providerActivityKey(entry)}`;
|
||||
// Each retry keeps its own position, so work between two retries in
|
||||
// one turn cannot make the later retry look recovered.
|
||||
const key = entry.family === "provider_notice" && entry.payload.category === "provider_retry"
|
||||
? `provider:${providerActivityKey(entry)}:${i}`
|
||||
: `provider:${providerActivityKey(entry)}`;
|
||||
const item = providerActivityItem(entry, runId, i);
|
||||
const existingIndex = protocolIndexByKey.get(key);
|
||||
if (existingIndex == null) {
|
||||
@@ -1332,8 +1338,17 @@ export function paperclipRunnerActivityItems(
|
||||
* terminal receipt at the request's first-seen position.
|
||||
*/
|
||||
export function paperclipRunnerTimelineItems(
|
||||
parsed: readonly TaskChatItem[],
|
||||
allParsed: readonly TaskChatItem[],
|
||||
): TaskChatItem[] {
|
||||
// Decide retry recovery on the full transcript: the final answer that
|
||||
// proves recovery is filtered out of the display rows below.
|
||||
const parsed = allParsed.map((item, index) =>
|
||||
item.kind === "protocol" && item.surface === "provider_activity" &&
|
||||
statusNoticeCategory(item) === "provider_retry" && !item.resolved &&
|
||||
allParsed.slice(index + 1).some(statusNoticeResolvesRetry)
|
||||
? { ...item, resolved: true }
|
||||
: item,
|
||||
);
|
||||
const activityIds = new Set(
|
||||
paperclipRunnerActivityItems(parsed).map((item) => item.id),
|
||||
);
|
||||
@@ -1827,6 +1842,37 @@ export function buildTurnTimelineRows(
|
||||
} else if (item.kind === "plan_document") {
|
||||
current = null;
|
||||
rows.push(item);
|
||||
} else if (
|
||||
item.kind === "protocol" &&
|
||||
item.surface === "provider_activity" &&
|
||||
isStatusNotice(item)
|
||||
) {
|
||||
// Harness status notices sit between activity as their own chips, so
|
||||
// a retry or compaction is visible without opening a phase.
|
||||
const later = parsed.slice(parsed.indexOf(item) + 1);
|
||||
const retry = statusNoticeCategory(item) === "provider_retry";
|
||||
// A fatal error that ends the retries already tells the whole story.
|
||||
if (retry && later.some((next) => next.kind === "protocol" && next.surface === "provider_activity" && isFatalProviderError(next))) continue;
|
||||
// Consecutive retries with no work between them show only the latest.
|
||||
const nextSignal = later.find((next) => statusNoticeResolvesRetry(next) ||
|
||||
(next.kind === "protocol" && next.surface === "provider_activity" && statusNoticeCategory(next) === "provider_retry"));
|
||||
if (retry && nextSignal?.kind === "protocol" && nextSignal.surface === "provider_activity" &&
|
||||
statusNoticeCategory(nextSignal) === "provider_retry") continue;
|
||||
// A completed compaction with a canonical context.compacted divider
|
||||
// after it is shown by that divider alone.
|
||||
if (statusNoticeCategory(item) === "context_compaction" &&
|
||||
item.details.find((detail) => detail.label === "Phase")?.value === "completed" &&
|
||||
later.some(isCompactionDivider)) continue;
|
||||
current = null;
|
||||
// A compaction still "running" when the turn has ended was cut short.
|
||||
const stoppedCompaction = !running && statusNoticeCategory(item) === "context_compaction" &&
|
||||
item.details.find((detail) => detail.label === "Phase")?.value === "running";
|
||||
const resolved = retry && (item.resolved === true || later.some(statusNoticeResolvesRetry));
|
||||
// A retry with no later work in a turn that has ended never recovered.
|
||||
const stoppedRetry = retry && !resolved && !running;
|
||||
rows.push(resolved
|
||||
? { ...item, resolved: true }
|
||||
: stoppedCompaction || stoppedRetry ? { ...item, status: "interrupted" } : item);
|
||||
} else if (
|
||||
item.kind === "tool" ||
|
||||
item.kind === "usage" ||
|
||||
@@ -1848,6 +1894,36 @@ export function buildTurnTimelineRows(
|
||||
return meaningful;
|
||||
}
|
||||
|
||||
/**
|
||||
* A canonical context.compacted item: a context provider activity, or the
|
||||
* context-meter divider marker (variant compared as text so this works
|
||||
* before and after that variant joins the marker union).
|
||||
*/
|
||||
function isCompactionDivider(item: TaskChatItem): boolean {
|
||||
if (item.kind === "protocol") return item.surface === "provider_activity" && item.family === "context";
|
||||
return item.kind === "marker" && (item.variant as string) === "context_compacted";
|
||||
}
|
||||
|
||||
/** Real provider progress after a retry notice shows the retry succeeded. */
|
||||
function statusNoticeResolvesRetry(item: TaskChatItem): boolean {
|
||||
// Only new provider work proves recovery. Run results, terminal records,
|
||||
// markers and usage also appear when a user stops a reconnecting run.
|
||||
switch (item.kind) {
|
||||
case "message":
|
||||
return item.author === "agent" && item.text.trim().length > 0;
|
||||
case "thinking":
|
||||
return item.lines.some((line) => line.trim().length > 0);
|
||||
case "tool":
|
||||
return true;
|
||||
case "protocol":
|
||||
// Workspace cards are re-positioned to the end of the turn, so their
|
||||
// place says nothing about when the work happened.
|
||||
return item.surface === "provider_activity" && !isStatusNotice(item);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Segment parsed transcript rows at assistant/request boundaries. */
|
||||
export function buildActivityPhases(
|
||||
parsed: readonly TaskChatItem[],
|
||||
|
||||
@@ -447,6 +447,8 @@
|
||||
--motion-diff-reveal: var(--motion-duration-base);
|
||||
--motion-status-enter: var(--motion-duration-fast);
|
||||
--motion-status-exit: var(--motion-duration-fast);
|
||||
/* Harness status chip fading to a receipt once a retry recovers. */
|
||||
--motion-status-notice-fade: var(--motion-duration-deliberate);
|
||||
--motion-approval-pulse: 1.6s;
|
||||
--motion-plan-entry-stagger: 40ms;
|
||||
--motion-plan-check: var(--motion-duration-fast);
|
||||
@@ -569,6 +571,12 @@
|
||||
--tc-mode-plan: var(--status-task-todo);
|
||||
--tc-mode-ask: #3b82f6;
|
||||
|
||||
/* Harness status chips (retry, model fallback, provider warnings) in the
|
||||
task-chat timeline: a quiet amber outline on the page surface. */
|
||||
--tc-status-notice-warning-fg: var(--status-task-icon-todo);
|
||||
--tc-status-notice-warning-border: color-mix(in oklch, var(--status-task-todo) 40%, transparent);
|
||||
--tc-status-notice-warning-bg: color-mix(in oklch, var(--status-task-todo) 8%, transparent);
|
||||
|
||||
/* Agent-comment comparison fill used by TaskChatLab. The chosen production
|
||||
treatment sits directly on the page surface; this token keeps the darker
|
||||
card available as an explicit lab alternative. */
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatToolItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
|
||||
type RunStatus = "running" | "succeeded" | "failed";
|
||||
|
||||
function tool(id: string, name: string, target: string, status: TaskChatToolItem["status"] = "completed"): TaskChatToolItem {
|
||||
return { id, kind: "tool", name, rawName: name.toLowerCase(), target, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same shape the live transcript adapter builds from a canonical
|
||||
* `provider.notice.recorded` event: payload keys become title-cased details.
|
||||
*/
|
||||
function notice(
|
||||
id: string,
|
||||
fields: { category: string; severity: "info" | "warning" | "error"; recoverable: boolean; summary?: string },
|
||||
extra: Record<string, string> = {},
|
||||
): TaskChatProviderActivityItem {
|
||||
return {
|
||||
id,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "provider_notice",
|
||||
eventType: "provider.notice.recorded",
|
||||
status: fields.severity === "error" && !fields.recoverable ? "failed" : "informational",
|
||||
title: "Provider notice",
|
||||
summary: fields.summary,
|
||||
details: [
|
||||
{ label: "Severity", value: fields.severity },
|
||||
{ label: "Category", value: fields.category },
|
||||
{ label: "Scope", value: "turn" },
|
||||
{ label: "Recoverable", value: String(fields.recoverable) },
|
||||
...(fields.summary ? [{ label: "Summary", value: fields.summary }] : []),
|
||||
...Object.entries(extra).map(([label, value]) => ({ label, value })),
|
||||
],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function progress(id: string, text: string): TaskChatMessageItem {
|
||||
return { id, kind: "message", author: "agent", channel: "progress", interstitial: true, text };
|
||||
}
|
||||
|
||||
function final(id: string, text: string): TaskChatMessageItem {
|
||||
return { id, kind: "message", author: "agent", channel: "final", interstitial: false, text, timestamp: "2:41 PM" };
|
||||
}
|
||||
|
||||
const compactingNow = notice("compaction-1", { category: "context_compaction", severity: "info", recoverable: true }, { Phase: "running" });
|
||||
const compacted = notice("compaction-1", { category: "context_compaction", severity: "info", recoverable: true }, { Phase: "completed" });
|
||||
const compactionFailed = notice("compaction-1", {
|
||||
category: "context_compaction", severity: "warning", recoverable: true,
|
||||
summary: "Conversation too long to summarize; continuing with the full context.",
|
||||
}, { Phase: "failed" });
|
||||
|
||||
const overloadRetry = notice("retry-1", {
|
||||
category: "provider_retry", severity: "warning", recoverable: true,
|
||||
summary: "We're currently experiencing high demand, which may cause temporary errors.",
|
||||
}, { Reason: "serverOverloaded", Attempt: "2", "Max attempts": "5" });
|
||||
|
||||
const streamRetry = notice("retry-2", {
|
||||
category: "provider_retry", severity: "warning", recoverable: true,
|
||||
summary: "stream disconnected before completion: error sending request for url (https://chatgpt.com/backend-api/codex/responses)",
|
||||
}, { Reason: "responseStreamDisconnected", Attempt: "1", "Max attempts": "5" });
|
||||
|
||||
const fallback = notice("fallback-1", {
|
||||
category: "model_fallback", severity: "warning", recoverable: true,
|
||||
summary: "claude-opus-5-5 declined this request; retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5.",
|
||||
}, { "Original model": "claude-opus-5-5", "Fallback model": "claude-sonnet-5-5", Reason: "declined the request (cyber)" });
|
||||
|
||||
const hookWarning = notice("info-1", {
|
||||
category: "provider_informational", severity: "warning", recoverable: true,
|
||||
summary: "Stop hook blocked continuation: lint must pass before finishing (pnpm lint exited 1).",
|
||||
});
|
||||
|
||||
const fatal = notice("error-1", {
|
||||
category: "error", severity: "error", recoverable: false,
|
||||
summary: "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), or try again at 4:12 PM.",
|
||||
}, { "Codex error": "usageLimitExceeded" });
|
||||
|
||||
const reading = [
|
||||
tool("t1", "Read", "server/src/services/webhooks/delivery-worker.ts"),
|
||||
tool("t2", "Search", "\"deliverWebhook\" in server/src"),
|
||||
tool("t3", "Read", "server/src/services/webhooks/retry-policy.ts"),
|
||||
];
|
||||
const editing = [
|
||||
tool("t4", "Edit", "server/src/services/webhooks/delivery-worker.ts"),
|
||||
tool("t5", "Bash", "pnpm vitest run server/src/services/webhooks"),
|
||||
];
|
||||
|
||||
const ANSWER = "Deliveries are now idempotent on the event id: the worker records the id before sending and skips ids it has already delivered. I added a regression test that retries the same event twice and asserts a single POST. The webhook suite passes (14 tests).";
|
||||
|
||||
interface Scenario {
|
||||
title: string;
|
||||
rule: string;
|
||||
agent: string;
|
||||
status: RunStatus;
|
||||
items: TaskChatItem[];
|
||||
}
|
||||
|
||||
const SCENARIOS = {
|
||||
compacting: {
|
||||
title: "Compacting context (Claude)",
|
||||
rule: "A spinner chip while the harness summarizes earlier context. It never joins the answer text.",
|
||||
agent: "ClaudeCoder", status: "running",
|
||||
items: [progress("p1", "Tracing the retry path through the delivery worker."), ...reading, compactingNow],
|
||||
},
|
||||
compacted: {
|
||||
title: "Context compacted, work continues",
|
||||
rule: "The chip settles to a quiet receipt and later activity continues below it.",
|
||||
agent: "ClaudeCoder", status: "succeeded",
|
||||
items: [progress("p1", "Tracing the retry path through the delivery worker."), ...reading, compacted, ...editing, final("f1", ANSWER)],
|
||||
},
|
||||
compactionFailed: {
|
||||
title: "Compaction failed",
|
||||
rule: "Amber, with the harness's reason. The run keeps going.",
|
||||
agent: "ClaudeCoder", status: "running",
|
||||
items: [...reading, compactionFailed, tool("t4", "Edit", "server/src/services/webhooks/delivery-worker.ts", "in_progress")],
|
||||
},
|
||||
retrying: {
|
||||
title: "Retrying after overload (Codex)",
|
||||
rule: "Amber while the provider is retrying, with the attempt count and the provider's real message.",
|
||||
agent: "CodexCoder", status: "running",
|
||||
items: [...reading, overloadRetry],
|
||||
},
|
||||
recovered: {
|
||||
title: "Retry recovered",
|
||||
rule: "Once real work follows, the retry chip fades to “Recovered after retry”.",
|
||||
agent: "CodexCoder", status: "succeeded",
|
||||
items: [...reading, streamRetry, ...editing, final("f1", ANSWER)],
|
||||
},
|
||||
fallback: {
|
||||
title: "Model fallback (Claude)",
|
||||
rule: "Names the model the session moved to and why. Click for the full explanation.",
|
||||
agent: "ClaudeCoder", status: "succeeded",
|
||||
items: [...reading, fallback, ...editing, final("f1", ANSWER)],
|
||||
},
|
||||
hookWarning: {
|
||||
title: "Provider warning banner",
|
||||
rule: "Claude's bold “Warning:” banners become an amber chip instead of answer text.",
|
||||
agent: "ClaudeCoder", status: "running",
|
||||
items: [...reading, ...editing, hookWarning],
|
||||
},
|
||||
fatal: {
|
||||
title: "Fatal provider error",
|
||||
rule: "A red row with the provider's real message, not “Provider notice”.",
|
||||
agent: "CodexCoder", status: "failed",
|
||||
items: [...reading, overloadRetry, fatal],
|
||||
},
|
||||
} satisfies Record<string, Scenario>;
|
||||
|
||||
function Thread({ scenario }: { scenario: Scenario }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<TaskChatBubble
|
||||
item={{ id: "ask", kind: "message", author: "human", authorName: "Alex", isCurrentUser: true, text: "Webhooks are delivered twice when the worker retries. Please fix it and add a test.", timestamp: "2:30 PM" }}
|
||||
animateEntry={false}
|
||||
/>
|
||||
<TaskChatRunnerTurn
|
||||
runId={`run-${scenario.title}`}
|
||||
agentName={scenario.agent}
|
||||
items={scenario.items}
|
||||
status={scenario.status}
|
||||
startedAtMs={Date.now() - 95_000}
|
||||
finishedAtMs={scenario.status === "running" ? undefined : Date.now() - 5_000}
|
||||
showHeader
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ scenario, children }: { scenario: Scenario; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{scenario.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{scenario.rule}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-10">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Gallery() {
|
||||
return (
|
||||
<Page>
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">Harness status notices</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Compaction, retries, model fallback and provider banners render as chips between the turn's activity.
|
||||
They come from canonical <code className="font-mono text-xs">provider.notice.recorded</code> events with a
|
||||
status category, so they stay out of the answer and the issue comment.
|
||||
</p>
|
||||
</header>
|
||||
{Object.values(SCENARIOS).map((scenario) => (
|
||||
<Card key={scenario.title} scenario={scenario}><Thread scenario={scenario} /></Card>
|
||||
))}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Single({ scenario }: { scenario: Scenario }) {
|
||||
return <Page><Card scenario={scenario}><Thread scenario={scenario} /></Card></Page>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "product-task-chat-status-notices",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Status notices",
|
||||
component: Single,
|
||||
args: { scenario: SCENARIOS.retrying },
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
paperclip: {
|
||||
"capabilities": [
|
||||
"tasks/discussion",
|
||||
"runs-adapters/runs"
|
||||
],
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "Renders the shipped runner turn with fixture provider notices shaped like the live transcript adapter output. Live provider event delivery is not exercised."
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Single>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const AllStatesGallery: Story = { render: () => <Gallery /> };
|
||||
export const CompactingContext: Story = { args: { scenario: SCENARIOS.compacting } };
|
||||
export const ContextCompacted: Story = { args: { scenario: SCENARIOS.compacted } };
|
||||
export const CompactionFailed: Story = { args: { scenario: SCENARIOS.compactionFailed } };
|
||||
export const RetryingAfterOverload: Story = { args: { scenario: SCENARIOS.retrying } };
|
||||
export const RetryRecovered: Story = { args: { scenario: SCENARIOS.recovered } };
|
||||
export const ModelFallback: Story = { args: { scenario: SCENARIOS.fallback } };
|
||||
export const ProviderWarningBanner: Story = { args: { scenario: SCENARIOS.hookWarning } };
|
||||
export const FatalProviderError: Story = { args: { scenario: SCENARIOS.fatal } };
|
||||
export const Light: Story = { render: () => <Gallery />, globals: { theme: "light" } };
|
||||
Reference in new issue
Block a user