mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(task-chat): warn at the top of a run when an MCP server failed to start (#19289)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Each agent run shows its activity in the task chat. The Paperclip runner turns harness events into that activity. > - Codex reports `mcpServer/startupStatus/updated` when an MCP server fails to start. The runner dropped this event at the notification allowlist and in the Rust normalizer. > - When an MCP server fails, the agent runs without its tools. The agent then says "I could not reach the server", and the user cannot see why. > - This pull request maps a failed MCP start to a user-facing provider notice and shows it as one warning line at the top of the run. > - The benefit is that the cause of a "cannot reach my tools" answer is visible next to that answer. ## Linked Issues or Issue Description Refs #19252 (the plan card, which set the pattern for promoting a dropped harness signal to a dedicated task-chat surface). **Problem description** Codex sends `mcpServer/startupStatus/updated` (`name`, `status`, `error`, `failureReason`) during `thread/start`. The TypeScript allowlist (`codex-thread-normalization.ts`) and the Rust normalizer (`runner-core/src/provider_events.rs`) both dropped it. A run with a broken MCP server showed no sign of the failure. The agent only said that it could not reach the tools. **Proposed solution** Map `status: "failed"` to `provider.notice.recorded` with `category: "mcp_server_startup_failed"`, `severity: "warning"`, `scope: "session"` and `userActionable: true`. Show these notices as one run-health line under the runner header: "github MCP server failed to start: No such file or directory (os error 2)". **Alternatives considered** An inline notice row in the timeline. It scrolls away and sits among tool rows. A run-level line next to the header is easier to connect to the agent's answer. ## What Changed - Runner (Rust, production Codex path): `normalize_codex_notification` maps a failed `mcpServer/startupStatus/updated` to a `provider.notice.recorded` notice. `starting`, `ready` and `cancelled` stay out of the transcript. The new helper removes the Codex wrapper text ("MCP client for \`x\` failed to start: MCP startup failed:") so that the line shows the real reason. `reauthenticationRequired` becomes "needs re-authentication". - Runner (TypeScript): the same mapping in `canonicalProviderEventsFromCodex`, and the method is added to `isSupportedCodexNotificationMethod`. - Server: the `view=conversation` run-event query keeps `mcp_server_startup_failed` notices. A settled run can then still show the warning. Other notices stay out of that view. - UI: new `TaskChatRunHealthBanner`. `partitionRunHealthItems` takes MCP failures out of the run's items, with one line per server where the latest report wins. `TaskChatRunnerTurn` (live runs) and `TaskChatTurn` (settled, historical and merged turns) show it under the header. `coalesceSettledTurns` merges the failures when it merges turns. - Storybook: `Tasks and conversations/…/Task chat/Run health` shows one failure, several failures, a finished run and a healthy run, all through the production runner turn. ## Verification - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/provider-events.test.ts src/drivers/codex src/protocol`: 508 tests pass. - `cargo test --release -p paperclip-runner-core --lib -- provider_events`: 20 tests pass. - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat src/components/transcript src/components/TaskChatThread.test.tsx`: 1340 tests pass, including the new `TaskChatRunHealthBanner.test.tsx` (persisted event to rendered banner, deduplication, settled historical turn, merged turns). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/task-cold-open.test.ts`: the conversation view keeps the MCP notice and still excludes other notices. - `pnpm check:token-gates` is clean. `node scripts/typecheck-low-memory.mjs` passes. - Live test on a local instance with Codex 0.160: a Codex agent with a preconfigured Codex home whose `config.toml` has `[mcp_servers.github] command = "github-mcp-server-not-installed"`. Codex reported the failure during `thread/start`. The persisted event had the summary "github MCP server failed to start: No such file or directory (os error 2)". The banner showed while the run was live. It also showed on the settled turn after "More run output", above the agent's answer that it could not reach the GitHub MCP server. ## Risks - Low risk. The change adds one notice category. It does not change how existing notices map or render. - A settled run whose transcript is deferred on a cold open (a successful run with a posted agent comment) shows the warning only after "More run output". This is the same as all other run activity under the current compact history. - Claude does not report MCP startup failures over ACP. `claude-agent-acp` reads MCP status only for OAuth. This change is therefore Codex-only. - Provisional task titles from provider session names are not part of this change. Codex app-server does not generate thread names (only its TUI does). Claude generates its title after the turn ends, and ACPX drops the title text. > 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`), through Claude Code with tool use (shell, file edits, browser automation for Storybook and the live test). ## 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 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
99c68455e6
commit
cb0b7525e0
15 files changed
+759
-11
No files matched your search
@@ -608,6 +608,23 @@ fn codex_terminal_error(params: &Value, event_type: &str) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn mcp_startup_error_reason(error: &str) -> String {
|
||||||
|
let redacted = redact_text(error);
|
||||||
|
let mut reason = redacted.trim();
|
||||||
|
if let Some(rest) = reason.strip_prefix("MCP client for `") {
|
||||||
|
if let Some(index) = rest.find("` failed to start:") {
|
||||||
|
reason = rest[index + "` failed to start:".len()..].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rest) = reason.strip_prefix("MCP startup failed:") {
|
||||||
|
reason = rest.trim();
|
||||||
|
}
|
||||||
|
reason.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn string(value: Option<&Value>) -> &str {
|
fn string(value: Option<&Value>) -> &str {
|
||||||
value.and_then(Value::as_str).unwrap_or("")
|
value.and_then(Value::as_str).unwrap_or("")
|
||||||
}
|
}
|
||||||
@@ -971,6 +988,61 @@ pub fn normalize_codex_notification_in(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 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"
|
||||||
|
if params.get("status").and_then(Value::as_str) == Some("failed") =>
|
||||||
|
{
|
||||||
|
let name: String = string(params.get("name"))
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.take(160)
|
||||||
|
.collect();
|
||||||
|
let server_name = if name.is_empty() {
|
||||||
|
"An".to_owned()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
let failure_reason = string(params.get("failureReason"));
|
||||||
|
let reason = if failure_reason == "reauthenticationRequired" {
|
||||||
|
"needs re-authentication".to_owned()
|
||||||
|
} else {
|
||||||
|
mcp_startup_error_reason(string(params.get("error")))
|
||||||
|
};
|
||||||
|
let notice_id = stable_id(&format!("mcp-startup:{server_name}"), "mcp-startup");
|
||||||
|
let mut details = vec![json!({"name": "server", "value": server_name})];
|
||||||
|
if !reason.is_empty() {
|
||||||
|
details.push(
|
||||||
|
json!({"name": "reason", "value": bounded_text(&reason, MAX_TEXT_CHARS)}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !failure_reason.is_empty() {
|
||||||
|
details.push(
|
||||||
|
json!({"name": "failureReason", "value": bounded_text(failure_reason, 160)}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let summary = if reason.is_empty() {
|
||||||
|
format!("{server_name} MCP server failed to start")
|
||||||
|
} else {
|
||||||
|
format!("{server_name} MCP server failed to start: {reason}")
|
||||||
|
};
|
||||||
|
push(
|
||||||
|
&mut events,
|
||||||
|
"provider.notice.recorded",
|
||||||
|
EventPriority::P0,
|
||||||
|
json!({
|
||||||
|
"schema": "paperclip.provider.notice.v1",
|
||||||
|
"noticeId": notice_id,
|
||||||
|
"severity": "warning",
|
||||||
|
"category": "mcp_server_startup_failed",
|
||||||
|
"scope": "session",
|
||||||
|
"recoverable": true,
|
||||||
|
"userActionable": true,
|
||||||
|
"summary": bounded_text(&summary, MAX_TEXT_CHARS),
|
||||||
|
"details": details,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
"error" | "warning" | "deprecationNotice" | "configWarning" => push(
|
"error" | "warning" | "deprecationNotice" | "configWarning" => push(
|
||||||
&mut events,
|
&mut events,
|
||||||
"provider.notice.recorded",
|
"provider.notice.recorded",
|
||||||
@@ -1804,6 +1876,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_failed_codex_mcp_startup_to_user_facing_notice() {
|
||||||
|
let events = normalize_codex_notification(
|
||||||
|
"mcpServer/startupStatus/updated",
|
||||||
|
&json!({
|
||||||
|
"threadId": null, "name": "github", "status": "failed",
|
||||||
|
// Codex 0.160's own wording, observed on a live run.
|
||||||
|
"error": "MCP client for `github` failed to start: MCP startup failed: No such file or directory (os error 2)",
|
||||||
|
"failureReason": null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(events[0].event_type, "provider.notice.recorded");
|
||||||
|
assert_eq!(
|
||||||
|
events[0].payload,
|
||||||
|
json!({
|
||||||
|
"schema": "paperclip.provider.notice.v1",
|
||||||
|
"noticeId": "mcp-startup:github",
|
||||||
|
"severity": "warning",
|
||||||
|
"category": "mcp_server_startup_failed",
|
||||||
|
"scope": "session",
|
||||||
|
"recoverable": true,
|
||||||
|
"userActionable": true,
|
||||||
|
"summary": "github MCP server failed to start: No such file or directory (os error 2)",
|
||||||
|
"details": [
|
||||||
|
{"name": "server", "value": "github"},
|
||||||
|
{"name": "reason", "value": "No such file or directory (os error 2)"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let reauth = normalize_codex_notification(
|
||||||
|
"mcpServer/startupStatus/updated",
|
||||||
|
&json!({"name": "linear", "status": "failed", "failureReason": "reauthenticationRequired"}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reauth[0].payload["summary"],
|
||||||
|
"linear MCP server failed to start: needs re-authentication"
|
||||||
|
);
|
||||||
|
for status in ["starting", "ready", "cancelled"] {
|
||||||
|
assert!(normalize_codex_notification(
|
||||||
|
"mcpServer/startupStatus/updated",
|
||||||
|
&json!({"name": "github", "status": status}),
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_codex_notice_text_from_current_and_legacy_payloads() {
|
fn preserves_codex_notice_text_from_current_and_legacy_payloads() {
|
||||||
for method in ["configWarning", "deprecationNotice", "warning"] {
|
for method in ["configWarning", "deprecationNotice", "warning"] {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export function isSupportedCodexNotificationMethod(method: string): boolean {
|
|||||||
method === "model/rerouted" ||
|
method === "model/rerouted" ||
|
||||||
method === "model/verification" ||
|
method === "model/verification" ||
|
||||||
method === "model/safetyBuffering/updated" ||
|
method === "model/safetyBuffering/updated" ||
|
||||||
|
method === "mcpServer/startupStatus/updated" ||
|
||||||
method.startsWith("item/") ||
|
method.startsWith("item/") ||
|
||||||
method === "paperclip/canonicalProviderEvent" ||
|
method === "paperclip/canonicalProviderEvent" ||
|
||||||
method === "paperclip/runtimeRequestExpired" ||
|
method === "paperclip/runtimeRequestExpired" ||
|
||||||
|
|||||||
@@ -94,6 +94,48 @@ describe("provider-neutral events", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps a failed Codex MCP server start to a user-facing notice", () => {
|
||||||
|
const [event] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", {
|
||||||
|
threadId: null,
|
||||||
|
name: "paperclip",
|
||||||
|
status: "failed",
|
||||||
|
error: "MCP client for `paperclip` failed to start: program not found: paperclip-mcp",
|
||||||
|
failureReason: null,
|
||||||
|
});
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
eventType: "provider.notice.recorded",
|
||||||
|
itemId: "mcp-startup:paperclip",
|
||||||
|
payload: {
|
||||||
|
severity: "warning",
|
||||||
|
category: "mcp_server_startup_failed",
|
||||||
|
scope: "session",
|
||||||
|
userActionable: true,
|
||||||
|
summary: "paperclip MCP server failed to start: program not found: paperclip-mcp",
|
||||||
|
details: [
|
||||||
|
{ name: "server", value: "paperclip" },
|
||||||
|
{ name: "reason", value: "program not found: paperclip-mcp" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(validatePrpEvent(envelope(event)).ok).toBe(true);
|
||||||
|
|
||||||
|
const [reauth] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", {
|
||||||
|
name: "linear", status: "failed", error: null, failureReason: "reauthenticationRequired",
|
||||||
|
});
|
||||||
|
expect(reauth.payload.summary).toBe("linear MCP server failed to start: needs re-authentication");
|
||||||
|
// Codex 0.160's own wording, observed on a live run.
|
||||||
|
const [live] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", {
|
||||||
|
name: "github", status: "failed",
|
||||||
|
error: "MCP client for `github` failed to start: MCP startup failed: No such file or directory (os error 2)",
|
||||||
|
});
|
||||||
|
expect(live.payload.summary).toBe("github MCP server failed to start: No such file or directory (os error 2)");
|
||||||
|
expect(validatePrpEvent(envelope(reauth)).ok).toBe(true);
|
||||||
|
|
||||||
|
for (const status of ["starting", "ready", "cancelled"]) {
|
||||||
|
expect(canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", { name: "paperclip", status })).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies the complete qualified 18-variant Codex ThreadItem inventory", () => {
|
it("classifies the complete qualified 18-variant Codex ThreadItem inventory", () => {
|
||||||
expect(Object.keys(CODEX_THREAD_ITEM_CLASSIFICATION)).toEqual([
|
expect(Object.keys(CODEX_THREAD_ITEM_CLASSIFICATION)).toEqual([
|
||||||
"userMessage",
|
"userMessage",
|
||||||
|
|||||||
@@ -751,6 +751,43 @@ export function canonicalProviderEventsFromCodex(
|
|||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
if (method === "mcpServer/startupStatus/updated") {
|
||||||
|
// 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.
|
||||||
|
if (params.status !== "failed") return [];
|
||||||
|
const serverName = [...text(params.name).trim()].slice(0, 160).join("") || "An";
|
||||||
|
const failureReason = text(params.failureReason);
|
||||||
|
// Codex wraps the reason in sentences that repeat what the notice already
|
||||||
|
// says: "MCP client for `github` failed to start: MCP startup failed: <reason>".
|
||||||
|
const error = redactCodexDiagnostic(text(params.error))
|
||||||
|
.trim()
|
||||||
|
.replace(/^MCP client for `[^`]*` failed to start:\s*/i, "")
|
||||||
|
.replace(/^MCP startup failed:\s*/i, "")
|
||||||
|
.trim();
|
||||||
|
const reason = failureReason === "reauthenticationRequired"
|
||||||
|
? "needs re-authentication"
|
||||||
|
: error;
|
||||||
|
const noticeId = safeId(`mcp-startup:${serverName}`, "mcp-startup");
|
||||||
|
return [{
|
||||||
|
eventType: "provider.notice.recorded",
|
||||||
|
itemId: noticeId,
|
||||||
|
payload: {
|
||||||
|
schema: "paperclip.provider.notice.v1",
|
||||||
|
noticeId,
|
||||||
|
severity: "warning",
|
||||||
|
category: "mcp_server_startup_failed",
|
||||||
|
scope: "session",
|
||||||
|
recoverable: true,
|
||||||
|
userActionable: true,
|
||||||
|
summary: `${serverName} MCP server failed to start${reason ? `: ${reason}` : ""}`.slice(0, 4000),
|
||||||
|
details: [
|
||||||
|
{ name: "server", value: serverName },
|
||||||
|
...(reason ? [{ name: "reason", value: reason.slice(0, 4000) }] : []),
|
||||||
|
...(failureReason ? [{ name: "failureReason", value: failureReason.slice(0, 160) }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
if (method === "turn/plan/updated") {
|
if (method === "turn/plan/updated") {
|
||||||
const turnPlanId = safeId(text(params.turnId), "turn-plan");
|
const turnPlanId = safeId(text(params.turnId), "turn-plan");
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -167,14 +167,18 @@ const support = externalDatabase ? { supported: true } : await getEmbeddedPostgr
|
|||||||
event(6, "runtime_request.resolved", { requestId: "request" }),
|
event(6, "runtime_request.resolved", { requestId: "request" }),
|
||||||
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
|
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
|
||||||
event(8, "turn.completed", {}),
|
event(8, "turn.completed", {}),
|
||||||
|
event(9, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "mcp_server_startup_failed",
|
||||||
|
summary: "github MCP server failed to start: No such file or directory (os error 2)" }),
|
||||||
|
event(10, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "configWarning", summary: "diagnostic" }),
|
||||||
]);
|
]);
|
||||||
const service = createHeartbeatRunState(db);
|
const service = createHeartbeatRunState(db);
|
||||||
const first = await service.listEvents(runId, 0, 2, true);
|
const first = await service.listEvents(runId, 0, 2, true);
|
||||||
expect(first.map(e => e.seq)).toEqual([3, 4]);
|
expect(first.map(e => e.seq)).toEqual([3, 4]);
|
||||||
const rest = await service.listEvents(runId, 4, 1000, true);
|
const rest = await service.listEvents(runId, 4, 1000, true);
|
||||||
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]);
|
// A settled run keeps the MCP startup failure that explains missing tools.
|
||||||
|
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8, 9]);
|
||||||
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
|
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
|
||||||
expect(await service.listEvents(runId)).toHaveLength(8);
|
expect(await service.listEvents(runId)).toHaveLength(10);
|
||||||
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
|
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
|
||||||
const activity = activityService(db);
|
const activity = activityService(db);
|
||||||
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
|
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function planWriteCondition(runId: string) {
|
|||||||
* Canonical PRP conversation data, excluding ordinary historical tool activity.
|
* Canonical PRP conversation data, excluding ordinary historical tool activity.
|
||||||
* Successful document writes retain a body-free boundary for saved Plan cards.
|
* Successful document writes retain a body-free boundary for saved Plan cards.
|
||||||
* Keep request lifecycles so a cold open cannot hide a question or approval.
|
* Keep request lifecycles so a cold open cannot hide a question or approval.
|
||||||
|
* Keep MCP startup failures so a settled run still explains missing tools.
|
||||||
* Item identity may occur on started/completed rather than each text delta.
|
* Item identity may occur on started/completed rather than each text delta.
|
||||||
*/
|
*/
|
||||||
export function conversationEventCondition(runId: string) {
|
export function conversationEventCondition(runId: string) {
|
||||||
@@ -44,6 +45,10 @@ export function conversationEventCondition(runId: string) {
|
|||||||
${planWriteCondition(runId)}
|
${planWriteCondition(runId)}
|
||||||
)
|
)
|
||||||
or ${heartbeatRunEvents.eventType} in ('run.result.accepted', 'run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted')
|
or ${heartbeatRunEvents.eventType} in ('run.result.accepted', 'run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted')
|
||||||
|
or (
|
||||||
|
${heartbeatRunEvents.eventType} = 'provider.notice.recorded'
|
||||||
|
and ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,category}' = 'mcp_server_startup_failed'
|
||||||
|
)
|
||||||
or (
|
or (
|
||||||
${heartbeatRunEvents.eventType} in ('item.started', 'item.delta', 'item.completed')
|
${heartbeatRunEvents.eventType} in ('item.started', 'item.delta', 'item.completed')
|
||||||
and (lower(replace(coalesce(${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
|
and (lower(replace(coalesce(${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
|
||||||
|
|||||||
@@ -248,6 +248,37 @@ describe.each(["legacy", "native"] as const)("historical %s system status", (run
|
|||||||
expect(props.onResumeAssignee).not.toHaveBeenCalled();
|
expect(props.onResumeAssignee).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(runtimeMode === "native")("keeps a failed MCP start on a historical settled run while hiding other notices", () => {
|
||||||
|
const prp = (seq: number, eventType: string, payload: Record<string, unknown>) => ({
|
||||||
|
id: seq, companyId: "company", runId: "mcp", agentId: "agent", seq, eventType, stream: "system", level: "info",
|
||||||
|
color: null, message: null, createdAt: new Date(`2025-01-01T10:00:0${seq}Z`),
|
||||||
|
payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceEventId: `event-${seq}`, sourceSeq: seq,
|
||||||
|
sourceInstanceId: "runner", sourceKind: "runner", runId: "mcp", normalizedSessionId: "session", eventType,
|
||||||
|
schemaVersion: 1, priority: 1, emittedAt: `2025-01-01T10:00:0${seq}.000Z`, payload } },
|
||||||
|
}) as Parameters<typeof nativeRunEventsToTranscript>[0][number];
|
||||||
|
const notice = (seq: number, category: string, summary: string, details: unknown[] = []) => prp(seq, "provider.notice.recorded", {
|
||||||
|
schema: "paperclip.provider.notice.v1", noticeId: `notice-${seq}`, severity: "warning", category, scope: "session",
|
||||||
|
recoverable: true, userActionable: true, summary, details,
|
||||||
|
});
|
||||||
|
nativeTranscriptState.transcriptByRun.set("mcp", [
|
||||||
|
...nativeRunEventsToTranscript([
|
||||||
|
notice(1, "mcp_server_startup_failed", "github MCP server failed to start: No such file or directory (os error 2)",
|
||||||
|
[{ name: "server", value: "github" }, { name: "reason", value: "No such file or directory (os error 2)" }]),
|
||||||
|
notice(2, "auth", "Sign in again"),
|
||||||
|
]),
|
||||||
|
{ kind: "assistant", channel: "progress", ts: "2025-01-01T10:00:05Z", text: "Inspected the source." },
|
||||||
|
{ kind: "assistant", channel: "final", ts: "2025-01-01T10:00:20Z", text: "I couldn't reach the GitHub MCP server." },
|
||||||
|
]);
|
||||||
|
render(<TaskChatThread comments={[comment("response", "I couldn't reach the GitHub MCP server.", "mcp")]}
|
||||||
|
linkedRuns={[run("mcp", "succeeded", 0)]} onAdd={async () => {}} issueStatus="done" />);
|
||||||
|
const banners = container.querySelectorAll('[data-testid="task-chat-run-health-banner"]');
|
||||||
|
expect([...banners].map((banner) => banner.textContent)).toEqual([
|
||||||
|
"github MCP server failed to start: No such file or directory (os error 2)",
|
||||||
|
]);
|
||||||
|
expect(container.textContent).not.toContain("Sign in again");
|
||||||
|
expect(container.textContent).toContain("I couldn't reach the GitHub MCP server.");
|
||||||
|
});
|
||||||
|
|
||||||
it("replaces old failure and wait notices when a new attempt succeeds, regardless of fetch order", () => {
|
it("replaces old failure and wait notices when a new attempt succeeds, regardless of fetch order", () => {
|
||||||
setTranscript("old");
|
setTranscript("old");
|
||||||
const notice = { ...comment("notice", "Old recovery details."), authorType: "system" as const,
|
const notice = { ...comment("notice", "Old recovery details."), authorType: "system" as const,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts";
|
import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts";
|
||||||
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
||||||
import { TaskChatRunnerHeader, TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
import { TaskChatRunnerHeader, TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||||
|
import { partitionRunHealthItems } from "@/components/task-chat/TaskChatRunHealthBanner";
|
||||||
import {
|
import {
|
||||||
TaskChatTurnStatusIsland,
|
TaskChatTurnStatusIsland,
|
||||||
taskChatTurnStatusModel,
|
taskChatTurnStatusModel,
|
||||||
@@ -2058,6 +2059,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
return {
|
return {
|
||||||
segment,
|
segment,
|
||||||
parsed,
|
parsed,
|
||||||
|
// Historical turns drop provider notices, but a failed MCP start
|
||||||
|
// still explains that run's answer, so take it before the filter.
|
||||||
|
mcpStartupFailures: partitionRunHealthItems(parsed).failures,
|
||||||
timelineItems: sourceIsPaperclipRunner
|
timelineItems: sourceIsPaperclipRunner
|
||||||
? paperclipRunnerTimelineItems(visibleActivity)
|
? paperclipRunnerTimelineItems(visibleActivity)
|
||||||
: visibleActivity,
|
: visibleActivity,
|
||||||
@@ -2112,9 +2116,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
allowFallback: sourceAllowSummaryFallback,
|
allowFallback: sourceAllowSummaryFallback,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const children = settledRunChildren(
|
const runHealth = {
|
||||||
timelineItemsBySegment[segmentIndex] ?? [],
|
failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [],
|
||||||
);
|
rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest,
|
||||||
|
};
|
||||||
|
const children = settledRunChildren(runHealth.rest);
|
||||||
if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner)
|
if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner)
|
||||||
continue;
|
continue;
|
||||||
settledRunIds.add(source.id);
|
settledRunIds.add(source.id);
|
||||||
@@ -2155,6 +2161,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0,
|
continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0,
|
||||||
animateFold: liveSeenRef.current.has(source.id),
|
animateFold: liveSeenRef.current.has(source.id),
|
||||||
items: children,
|
items: children,
|
||||||
|
...(runHealth.failures.length > 0 ? { mcpStartupFailures: runHealth.failures } : {}),
|
||||||
finalResponse,
|
finalResponse,
|
||||||
summary: buildTurnSummary(segment.entries, {
|
summary: buildTurnSummary(segment.entries, {
|
||||||
durationMs: segmentDurationMs,
|
durationMs: segmentDurationMs,
|
||||||
@@ -2209,12 +2216,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
liveRun.id === planDocumentSourceRunId && planTurnItem
|
liveRun.id === planDocumentSourceRunId && planTurnItem
|
||||||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||||||
: parsedTranscript;
|
: parsedTranscript;
|
||||||
const children = settledRunChildren(
|
const runHealth = partitionRunHealthItems(
|
||||||
isNativePaperclipRunnerRun(liveRun)
|
isNativePaperclipRunnerRun(liveRun)
|
||||||
? paperclipRunnerTimelineItems(parsed)
|
? paperclipRunnerTimelineItems(parsed)
|
||||||
: parsed,
|
: parsed,
|
||||||
);
|
);
|
||||||
if (children.length === 0) continue;
|
const children = settledRunChildren(runHealth.rest);
|
||||||
|
if (children.length === 0 && runHealth.failures.length === 0) continue;
|
||||||
const turnId = `${liveRun.id}:turn:${segmentIndex}`;
|
const turnId = `${liveRun.id}:turn:${segmentIndex}`;
|
||||||
const segmentDurationMs =
|
const segmentDurationMs =
|
||||||
Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs)
|
Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs)
|
||||||
@@ -2250,6 +2258,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
continuedAfterSteering:
|
continuedAfterSteering:
|
||||||
isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0,
|
isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0,
|
||||||
items: children,
|
items: children,
|
||||||
|
...(runHealth.failures.length > 0 ? { mcpStartupFailures: runHealth.failures } : {}),
|
||||||
summary: buildTurnSummary(segment.entries, {
|
summary: buildTurnSummary(segment.entries, {
|
||||||
durationMs: segmentDurationMs,
|
durationMs: segmentDurationMs,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ThemeProvider } from "@/context/ThemeContext";
|
||||||
|
import { MemoryRouter } from "@/lib/router";
|
||||||
|
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||||||
|
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||||
|
import { TaskChatTurn } from "./TaskChatTurn";
|
||||||
|
import { partitionRunHealthItems } from "./TaskChatRunHealthBanner";
|
||||||
|
import type { TaskChatTurnItem } from "./task-chat-model";
|
||||||
|
import { coalesceSettledTurns, transcriptToTaskChatItems } from "./transcript-adapter";
|
||||||
|
|
||||||
|
const RUN_ID = "10000000-0000-4000-8000-000000000001";
|
||||||
|
|
||||||
|
function event(seq: number, eventType: string, payload: Record<string, unknown>): HeartbeatRunEvent {
|
||||||
|
return {
|
||||||
|
id: seq,
|
||||||
|
companyId: "10000000-0000-4000-8000-000000000002",
|
||||||
|
runId: RUN_ID,
|
||||||
|
agentId: "10000000-0000-4000-8000-000000000003",
|
||||||
|
seq,
|
||||||
|
eventType,
|
||||||
|
stream: "system",
|
||||||
|
level: "info",
|
||||||
|
color: null,
|
||||||
|
message: null,
|
||||||
|
payload: {
|
||||||
|
prpEvent: {
|
||||||
|
schema: "paperclip.prp.event.v1",
|
||||||
|
sourceEventId: `event-${seq}`,
|
||||||
|
sourceSeq: seq,
|
||||||
|
sourceInstanceId: "runner-1",
|
||||||
|
sourceKind: "runner",
|
||||||
|
runId: RUN_ID,
|
||||||
|
normalizedSessionId: "session-1",
|
||||||
|
eventType,
|
||||||
|
schemaVersion: 1,
|
||||||
|
priority: 1,
|
||||||
|
emittedAt: `2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: new Date(`2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`),
|
||||||
|
} as HeartbeatRunEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The runner's mapping of a failed Codex mcpServer/startupStatus/updated. */
|
||||||
|
function mcpFailure(seq: number, server: string, reason: string) {
|
||||||
|
return event(seq, "provider.notice.recorded", {
|
||||||
|
schema: "paperclip.provider.notice.v1",
|
||||||
|
noticeId: `mcp-startup:${server}`,
|
||||||
|
severity: "warning",
|
||||||
|
category: "mcp_server_startup_failed",
|
||||||
|
scope: "session",
|
||||||
|
recoverable: true,
|
||||||
|
userActionable: true,
|
||||||
|
summary: `${server} MCP server failed to start: ${reason}`,
|
||||||
|
details: [{ name: "server", value: server }, { name: "reason", value: reason }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsFrom(events: HeartbeatRunEvent[]) {
|
||||||
|
return transcriptToTaskChatItems(nativeRunEventsToTranscript(events), { runId: RUN_ID, running: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run health banner", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const render = (events: HeartbeatRunEvent[]) =>
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<TaskChatRunnerTurn runId={RUN_ID} agentName="Runner" items={itemsFrom(events)} status="running" startedAtMs={Date.now() - 2_000} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("lifts a failed MCP start out of the timeline into one banner under the header", () => {
|
||||||
|
render([
|
||||||
|
mcpFailure(1, "paperclip", "program not found: paperclip-mcp"),
|
||||||
|
event(2, "provider.notice.recorded", {
|
||||||
|
schema: "paperclip.provider.notice.v1", noticeId: "other", severity: "warning",
|
||||||
|
category: "auth", scope: "session", recoverable: true, userActionable: true, summary: "Sign in again",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const banners = container.querySelectorAll('[data-testid="task-chat-run-health-banner"]');
|
||||||
|
expect(banners).toHaveLength(1);
|
||||||
|
expect(banners[0].textContent).toBe("paperclip MCP server failed to start: program not found: paperclip-mcp");
|
||||||
|
const inline = container.querySelectorAll('[data-activity-family="provider_notice"]');
|
||||||
|
expect([...inline].map((row) => row.textContent)).toEqual([expect.stringContaining("Sign in again")]);
|
||||||
|
const header = container.querySelector('[data-testid="task-chat-runner-identity-row"]');
|
||||||
|
expect(header?.nextElementSibling).toBe(banners[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names every failed server once and keeps the latest reason", () => {
|
||||||
|
const { failures, rest } = partitionRunHealthItems(itemsFrom([
|
||||||
|
mcpFailure(1, "github", "request timed out"),
|
||||||
|
mcpFailure(2, "linear", "needs re-authentication"),
|
||||||
|
mcpFailure(3, "github", "program not found: github-mcp-server"),
|
||||||
|
]));
|
||||||
|
expect(rest).toEqual([]);
|
||||||
|
expect(failures.map(({ serverName, reason }) => ({ serverName, reason }))).toEqual([
|
||||||
|
{ serverName: "github", reason: "program not found: github-mcp-server" },
|
||||||
|
{ serverName: "linear", reason: "needs re-authentication" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no banner and keeps the item array when every server started", () => {
|
||||||
|
const items = itemsFrom([event(1, "item.completed", { kind: "agentMessage", channel: "final", text: "Done." })]);
|
||||||
|
expect(partitionRunHealthItems(items).rest).toBe(items);
|
||||||
|
render([event(1, "item.completed", { kind: "agentMessage", channel: "final", text: "Done." })]);
|
||||||
|
expect(container.querySelector('[data-testid="task-chat-run-health-banner"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const settledTurn = (id: string, failures: TaskChatTurnItem["mcpStartupFailures"]): TaskChatTurnItem => ({
|
||||||
|
id, kind: "turn", settled: true, historical: true, standaloneHeader: true, agentName: "Runner",
|
||||||
|
items: [], mcpStartupFailures: failures, summary: { toolCount: 0, added: 0, removed: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the banner on a settled historical turn, above its activity", () => {
|
||||||
|
act(() => root.render(
|
||||||
|
<MemoryRouter><ThemeProvider>
|
||||||
|
<TaskChatTurn item={settledTurn("run-1:turn", [{ id: "f", serverName: "github", reason: "No such file or directory (os error 2)" }])} renderChild={() => null} />
|
||||||
|
</ThemeProvider></MemoryRouter>,
|
||||||
|
));
|
||||||
|
expect(container.querySelector('[data-testid="task-chat-run-health-banner"]')?.textContent)
|
||||||
|
.toBe("github MCP server failed to start: No such file or directory (os error 2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges failures when one agent's settled turns coalesce", () => {
|
||||||
|
const meta = { agentKey: "agent-1", agentName: "Runner", parts: [] };
|
||||||
|
const [merged] = coalesceSettledTurns([
|
||||||
|
settledTurn("a", [{ id: "1", serverName: "github", reason: "timed out" }]),
|
||||||
|
settledTurn("b", [{ id: "2", serverName: "github", reason: "program not found" }, { id: "3", serverName: "linear", reason: null }]),
|
||||||
|
], new Map([["a", meta], ["b", meta]]));
|
||||||
|
expect(merged.kind === "turn" && merged.mcpStartupFailures?.map(({ serverName, reason }) => [serverName, reason]))
|
||||||
|
.toEqual([["github", "program not found"], ["linear", null]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useId, useState } from "react";
|
||||||
|
import { ChevronRight, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TaskChatItem } from "./task-chat-model";
|
||||||
|
|
||||||
|
/** One MCP server that the harness could not start for this run. */
|
||||||
|
export interface TaskChatMcpStartupFailure {
|
||||||
|
id: string;
|
||||||
|
serverName: string;
|
||||||
|
/** Provider-reported reason, already bounded by the runner. */
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MCP_STARTUP_FAILED_CATEGORY = "mcp_server_startup_failed";
|
||||||
|
|
||||||
|
function detailValue(item: Extract<TaskChatItem, { kind: "protocol" }>, label: string) {
|
||||||
|
if (item.surface !== "provider_activity") return undefined;
|
||||||
|
return item.details.find((detail) => detail.label === label)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMcpStartupFailure(item: TaskChatItem): item is Extract<TaskChatItem, { kind: "protocol"; surface: "provider_activity" }> {
|
||||||
|
return item.kind === "protocol"
|
||||||
|
&& item.surface === "provider_activity"
|
||||||
|
&& item.family === "provider_notice"
|
||||||
|
&& detailValue(item, "Category") === MCP_STARTUP_FAILED_CATEGORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts MCP startup failures out of a run's items so they render once as the
|
||||||
|
* run-health banner instead of as inline notice rows. Later reports for the
|
||||||
|
* same server replace earlier ones.
|
||||||
|
*/
|
||||||
|
export function partitionRunHealthItems(items: readonly TaskChatItem[]): {
|
||||||
|
failures: TaskChatMcpStartupFailure[];
|
||||||
|
rest: TaskChatItem[];
|
||||||
|
} {
|
||||||
|
const byServer = new Map<string, TaskChatMcpStartupFailure>();
|
||||||
|
const rest: TaskChatItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!isMcpStartupFailure(item)) {
|
||||||
|
rest.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serverName = detailValue(item, "server") ?? "An";
|
||||||
|
byServer.set(serverName, { id: item.id, serverName, reason: detailValue(item, "reason") ?? null });
|
||||||
|
}
|
||||||
|
// Keep the caller's array identity when nothing was lifted out.
|
||||||
|
return { failures: [...byServer.values()], rest: byServer.size === 0 ? (items as TaskChatItem[]) : rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureLine(failure: TaskChatMcpStartupFailure) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">{failure.serverName}</span>
|
||||||
|
<span> MCP server failed to start</span>
|
||||||
|
{failure.reason ? <span className="text-muted-foreground">: {failure.reason}</span> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run-level health warning shown directly under the runner header. A failed
|
||||||
|
* MCP server explains a whole class of runs where the agent says it cannot
|
||||||
|
* reach its tools, so it stays visible above the transcript instead of
|
||||||
|
* scrolling away as one more activity row.
|
||||||
|
*/
|
||||||
|
export function TaskChatRunHealthBanner({ failures }: { failures: readonly TaskChatMcpStartupFailure[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const listId = `task-chat-run-health-${useId().replaceAll(":", "")}`;
|
||||||
|
if (failures.length === 0) return null;
|
||||||
|
const single = failures.length === 1 ? failures[0] : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="my-1 flex min-w-0 flex-col gap-1 rounded-md border border-border bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground"
|
||||||
|
data-testid="task-chat-run-health-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group/health flex min-w-0 items-center gap-2 text-left"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listId}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<TriangleAlert className="size-(--sz-protocol-notice-icon) shrink-0 text-(--status-task-icon-todo)" aria-hidden />
|
||||||
|
<span className={cn("min-w-0 flex-1", open ? "whitespace-normal break-words" : "truncate")}>
|
||||||
|
{single ? failureLine(single) : (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">{failures.length} MCP servers</span>
|
||||||
|
<span> failed to start: </span>
|
||||||
|
<span className="text-muted-foreground">{failures.map((failure) => failure.serverName).join(", ")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : "opacity-60 group-hover/health:opacity-100")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div id={listId} className="flex flex-col gap-1 pl-5">
|
||||||
|
{single ? null : failures.map((failure) => (
|
||||||
|
<p key={failure.id} className="min-w-0 whitespace-pre-wrap break-words">{failureLine(failure)}</p>
|
||||||
|
))}
|
||||||
|
<p>The agent ran without these tools. Fix the server's command or credentials, then try again.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
|
|||||||
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
|
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
|
||||||
import { formatTaskChatTimestamp } from "./task-chat-adapter";
|
import { formatTaskChatTimestamp } from "./task-chat-adapter";
|
||||||
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
|
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
|
||||||
|
import { TaskChatRunHealthBanner, partitionRunHealthItems } from "./TaskChatRunHealthBanner";
|
||||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||||
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
||||||
import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer";
|
import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer";
|
||||||
@@ -186,9 +187,10 @@ export function TaskChatRunnerTurn({
|
|||||||
decision: TaskChatRuntimeRequestDecision,
|
decision: TaskChatRuntimeRequestDecision,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const { failures: mcpStartupFailures, rest: turnItems } = partitionRunHealthItems(items);
|
||||||
const terminal = isTerminalRunStatus(status);
|
const terminal = isTerminalRunStatus(status);
|
||||||
const waitingLabel = runnerWaitingLabel({ runId, status, execution, preparationLabel, startedAtMs });
|
const waitingLabel = runnerWaitingLabel({ runId, status, execution, preparationLabel, startedAtMs });
|
||||||
const yielded = items.some(
|
const yielded = turnItems.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.kind === "protocol" &&
|
item.kind === "protocol" &&
|
||||||
item.surface === "run_result" &&
|
item.surface === "run_result" &&
|
||||||
@@ -196,12 +198,12 @@ export function TaskChatRunnerTurn({
|
|||||||
);
|
);
|
||||||
const observedFinal = suppressFinal
|
const observedFinal = suppressFinal
|
||||||
? undefined
|
? undefined
|
||||||
: paperclipRunnerFinalResponse(items, {
|
: paperclipRunnerFinalResponse(turnItems, {
|
||||||
allowFallback: terminal && allowSummaryFallback,
|
allowFallback: terminal && allowSummaryFallback,
|
||||||
});
|
});
|
||||||
const observedProviderText = Boolean(
|
const observedProviderText = Boolean(
|
||||||
observedFinal &&
|
observedFinal &&
|
||||||
items.some(
|
turnItems.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.kind === "message" &&
|
item.kind === "message" &&
|
||||||
item.id === observedFinal.id &&
|
item.id === observedFinal.id &&
|
||||||
@@ -233,7 +235,7 @@ export function TaskChatRunnerTurn({
|
|||||||
finalRef.current.providerText = observedProviderText;
|
finalRef.current.providerText = observedProviderText;
|
||||||
}
|
}
|
||||||
const final = finalRef.current.item;
|
const final = finalRef.current.item;
|
||||||
const timelineItems = paperclipRunnerTimelineItems(items);
|
const timelineItems = paperclipRunnerTimelineItems(turnItems);
|
||||||
const currentActivityItems = currentActivityStatusItems(timelineItems);
|
const currentActivityItems = currentActivityStatusItems(timelineItems);
|
||||||
const timelineRows = buildTurnTimelineRows(
|
const timelineRows = buildTurnTimelineRows(
|
||||||
omitProgressRepeatedByResponse(timelineItems, final?.text),
|
omitProgressRepeatedByResponse(timelineItems, final?.text),
|
||||||
@@ -249,6 +251,7 @@ export function TaskChatRunnerTurn({
|
|||||||
{showHeader ? <TaskChatRunnerHeader runId={runId} agentName={agentName} agentIcon={agentIcon} agent={agent}
|
{showHeader ? <TaskChatRunnerHeader runId={runId} agentName={agentName} agentIcon={agentIcon} agent={agent}
|
||||||
status={status} execution={execution} preparationLabel={preparationLabel} startedAtMs={startedAtMs}
|
status={status} execution={execution} preparationLabel={preparationLabel} startedAtMs={startedAtMs}
|
||||||
finishedAtMs={finishedAtMs} continuedAfterSteering={continuedAfterSteering} /> : null}
|
finishedAtMs={finishedAtMs} continuedAfterSteering={continuedAfterSteering} /> : null}
|
||||||
|
<TaskChatRunHealthBanner failures={mcpStartupFailures} />
|
||||||
{activityUnavailable ? (
|
{activityUnavailable ? (
|
||||||
<div
|
<div
|
||||||
className="px-1 py-1 text-xs text-muted-foreground"
|
className="px-1 py-1 text-xs text-muted-foreground"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TaskChatRunHealthBanner } from "./TaskChatRunHealthBanner";
|
||||||
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
||||||
import { Check, ChevronRight, X } from "lucide-react";
|
import { Check, ChevronRight, X } from "lucide-react";
|
||||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||||
@@ -108,6 +109,7 @@ export function TaskChatTurn({
|
|||||||
: "Worked"}
|
: "Worked"}
|
||||||
</span> : null}
|
</span> : null}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
|
<TaskChatRunHealthBanner failures={item.mcpStartupFailures ?? []} />
|
||||||
{item.items.length > 0 ? (
|
{item.items.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-col gap-2 py-1"
|
className="flex min-w-0 flex-col gap-2 py-1"
|
||||||
@@ -267,6 +269,7 @@ export function TaskChatTurn({
|
|||||||
) : (
|
) : (
|
||||||
header
|
header
|
||||||
)}
|
)}
|
||||||
|
<TaskChatRunHealthBanner failures={item.mcpStartupFailures ?? []} />
|
||||||
<div
|
<div
|
||||||
className="tc-turn-fold"
|
className="tc-turn-fold"
|
||||||
data-folded={folded ? "true" : "false"}
|
data-folded={folded ? "true" : "false"}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* inventory for the mapping. No timing/motion values live here — those are
|
* inventory for the mapping. No timing/motion values live here — those are
|
||||||
* CSS motion tokens in ui/src/index.css.
|
* CSS motion tokens in ui/src/index.css.
|
||||||
*/
|
*/
|
||||||
|
import type { TaskChatMcpStartupFailure } from "./TaskChatRunHealthBanner";
|
||||||
import type {
|
import type {
|
||||||
Issue,
|
Issue,
|
||||||
TaskBrowser,
|
TaskBrowser,
|
||||||
@@ -562,6 +563,8 @@ export interface TaskChatTurnItem {
|
|||||||
continuedAfterSteering?: boolean;
|
continuedAfterSteering?: boolean;
|
||||||
/** Durable response shown after the ordered Paperclip Runner timeline. */
|
/** Durable response shown after the ordered Paperclip Runner timeline. */
|
||||||
finalResponse?: TaskChatMessageItem;
|
finalResponse?: TaskChatMessageItem;
|
||||||
|
/** MCP servers the harness could not start for this run (run-health banner). */
|
||||||
|
mcpStartupFailures?: TaskChatMcpStartupFailure[];
|
||||||
summary: {
|
summary: {
|
||||||
/** e.g. "38s" — omitted when unknown. */
|
/** e.g. "38s" — omitted when unknown. */
|
||||||
durationLabel?: string;
|
durationLabel?: string;
|
||||||
|
|||||||
@@ -2038,6 +2038,18 @@ export interface SettledTurnMergeMeta {
|
|||||||
* the merged turn keeps the FIRST run's id (stable across re-renders), and
|
* the merged turn keeps the FIRST run's id (stable across re-renders), and
|
||||||
* animateFold survives if any merged run was seen live.
|
* animateFold survives if any merged run was seen live.
|
||||||
*/
|
*/
|
||||||
|
/** One banner line per server; the later run's report wins. */
|
||||||
|
function mergeMcpStartupFailures(
|
||||||
|
first: TaskChatTurnItem["mcpStartupFailures"],
|
||||||
|
second: TaskChatTurnItem["mcpStartupFailures"],
|
||||||
|
): TaskChatTurnItem["mcpStartupFailures"] {
|
||||||
|
if (!first?.length) return second;
|
||||||
|
if (!second?.length) return first;
|
||||||
|
const byServer = new Map(first.map((failure) => [failure.serverName, failure]));
|
||||||
|
for (const failure of second) byServer.set(failure.serverName, failure);
|
||||||
|
return [...byServer.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export function coalesceSettledTurns(
|
export function coalesceSettledTurns(
|
||||||
items: readonly TaskChatItem[],
|
items: readonly TaskChatItem[],
|
||||||
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
||||||
@@ -2062,9 +2074,11 @@ export function coalesceSettledTurns(
|
|||||||
) {
|
) {
|
||||||
out.splice(heldIdx, 1);
|
out.splice(heldIdx, 1);
|
||||||
const parts = [...heldMeta.parts, ...meta.parts];
|
const parts = [...heldMeta.parts, ...meta.parts];
|
||||||
|
const mcpStartupFailures = mergeMcpStartupFailures(held.mcpStartupFailures, item.mcpStartupFailures);
|
||||||
out.push({
|
out.push({
|
||||||
...held,
|
...held,
|
||||||
items: [...held.items, ...item.items],
|
items: [...held.items, ...item.items],
|
||||||
|
...(mcpStartupFailures ? { mcpStartupFailures } : {}),
|
||||||
finalResponse: item.finalResponse ?? held.finalResponse,
|
finalResponse: item.finalResponse ?? held.finalResponse,
|
||||||
animateFold: held.animateFold || item.animateFold || undefined,
|
animateFold: held.animateFold || item.animateFold || undefined,
|
||||||
summary: buildMergedTurnSummary(parts),
|
summary: buildMergedTurnSummary(parts),
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, userEvent, within } from "storybook/test";
|
||||||
|
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||||
|
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||||
|
import type { TaskChatMcpStartupFailure } from "@/components/task-chat/TaskChatRunHealthBanner";
|
||||||
|
import type { TaskChatItem, TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
|
||||||
|
|
||||||
|
const STARTED = Date.now() - 48_000;
|
||||||
|
|
||||||
|
const request: TaskChatMessageItem = {
|
||||||
|
id: "request",
|
||||||
|
kind: "message",
|
||||||
|
author: "human",
|
||||||
|
authorName: "Alex",
|
||||||
|
isCurrentUser: true,
|
||||||
|
text: "can you check why the webhook retries are sending duplicate deliveries to acme? started after yesterday's deploy",
|
||||||
|
timestamp: "9:41 AM",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAPERCLIP_FAILED: TaskChatMcpStartupFailure = {
|
||||||
|
id: "mcp:paperclip",
|
||||||
|
serverName: "paperclip",
|
||||||
|
reason: "handshaking with MCP server failed: connection closed: initialize response",
|
||||||
|
};
|
||||||
|
const GITHUB_FAILED: TaskChatMcpStartupFailure = {
|
||||||
|
id: "mcp:github",
|
||||||
|
serverName: "github",
|
||||||
|
reason: "program not found: github-mcp-server",
|
||||||
|
};
|
||||||
|
const LINEAR_FAILED: TaskChatMcpStartupFailure = {
|
||||||
|
id: "mcp:linear",
|
||||||
|
serverName: "linear",
|
||||||
|
reason: "request timed out after 10s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningItems: TaskChatItem[] = [
|
||||||
|
{ id: "think", kind: "thinking", lines: ["Looking for the delivery worker and its retry path."], streaming: false, channel: "summary", transcriptIndex: 1 },
|
||||||
|
{ id: "rg", kind: "tool", name: "Search", rawName: "rg", target: "rg -n \"retryDelivery\" server/src", status: "completed" },
|
||||||
|
{ id: "read", kind: "tool", name: "Read", rawName: "read_file", target: "server/src/services/webhooks/delivery-worker.ts", status: "completed" },
|
||||||
|
{ id: "test", kind: "tool", name: "Bash", rawName: "bash", target: "pnpm vitest run server/src/services/webhooks", status: "in_progress" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const answer: TaskChatMessageItem = {
|
||||||
|
id: "answer",
|
||||||
|
kind: "message",
|
||||||
|
author: "agent",
|
||||||
|
channel: "final",
|
||||||
|
text: "I couldn't reach the Paperclip tools in this run, so I can't post the findings to the task or update its status. From the code: the retry path re-enqueues a delivery before the first attempt records its event id, so a slow first attempt and its retry both send. I've drafted an idempotency check on the event id; the fix is in `delivery-worker.ts`.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedItems: TaskChatItem[] = [
|
||||||
|
...runningItems.map((item) => (item.kind === "tool" ? { ...item, status: "completed" as const } : item)),
|
||||||
|
answer,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The task-chat item the transcript adapter builds from the runner's provider.notice. */
|
||||||
|
function noticeItem(failure: TaskChatMcpStartupFailure): TaskChatItem {
|
||||||
|
const summary = `${failure.serverName} MCP server failed to start${failure.reason ? `: ${failure.reason}` : ""}`;
|
||||||
|
return {
|
||||||
|
id: `run-health:provider:${failure.id}`,
|
||||||
|
kind: "protocol",
|
||||||
|
surface: "provider_activity",
|
||||||
|
family: "provider_notice",
|
||||||
|
eventType: "provider.notice.recorded",
|
||||||
|
status: "informational",
|
||||||
|
title: "Provider notice",
|
||||||
|
summary,
|
||||||
|
details: [
|
||||||
|
{ label: "Severity", value: "warning" },
|
||||||
|
{ label: "Category", value: "mcp_server_startup_failed" },
|
||||||
|
{ label: "Scope", value: "session" },
|
||||||
|
{ label: "Summary", value: summary },
|
||||||
|
{ label: "server", value: failure.serverName, mono: false },
|
||||||
|
...(failure.reason ? [{ label: "reason", value: failure.reason, mono: false }] : []),
|
||||||
|
],
|
||||||
|
steps: [],
|
||||||
|
links: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run({
|
||||||
|
failures,
|
||||||
|
status = "running",
|
||||||
|
items = runningItems,
|
||||||
|
}: {
|
||||||
|
failures: readonly TaskChatMcpStartupFailure[];
|
||||||
|
status?: string;
|
||||||
|
items?: TaskChatItem[];
|
||||||
|
}) {
|
||||||
|
const terminal = status !== "running";
|
||||||
|
return (
|
||||||
|
<TaskChatRunnerTurn
|
||||||
|
runId="run-health"
|
||||||
|
agentName="Codex Engineer"
|
||||||
|
items={[...failures.map(noticeItem), ...items]}
|
||||||
|
status={status}
|
||||||
|
startedAtMs={STARTED}
|
||||||
|
finishedAtMs={terminal ? Date.now() : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Task({ title, provisional = false, children }: { title: string; provisional?: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-4 text-foreground md:p-8">
|
||||||
|
<div className="mx-auto flex max-w-(--tc-shell-max-w) flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-1">
|
||||||
|
<h1 className={provisional ? "truncate text-xl font-semibold text-muted-foreground" : "text-xl font-semibold"}>{title}</h1>
|
||||||
|
{provisional ? <p className="text-xs text-muted-foreground">Provisional title from the first message</p> : null}
|
||||||
|
</header>
|
||||||
|
<TaskChatBubble item={request} animateEntry={false} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{rule}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AllStates() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-10">
|
||||||
|
<header>
|
||||||
|
<h2 className="text-lg font-semibold">Run health</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A one-line warning under the runner header when the harness could not start an MCP server. It comes from
|
||||||
|
Codex <code className="font-mono text-xs">mcpServer/startupStatus/updated</code>, mapped to a user-facing{" "}
|
||||||
|
<code className="font-mono text-xs">provider.notice</code>.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<StateCard title="One server failed" rule="Server name, then the provider's reason, truncated to one line. Click to see the full reason.">
|
||||||
|
<Run failures={[PAPERCLIP_FAILED]} />
|
||||||
|
</StateCard>
|
||||||
|
<StateCard title="Several servers failed" rule="One line names them all. Expanding lists each reason.">
|
||||||
|
<Run failures={[PAPERCLIP_FAILED, GITHUB_FAILED, LINEAR_FAILED]} />
|
||||||
|
</StateCard>
|
||||||
|
<StateCard title="Run finished" rule="The warning stays on the turn after the run ends, so the agent's “I couldn't reach the tools” answer has its cause beside it.">
|
||||||
|
<Run failures={[GITHUB_FAILED]} status="succeeded" items={completedItems} />
|
||||||
|
</StateCard>
|
||||||
|
<StateCard title="Healthy run" rule="No banner when every MCP server started.">
|
||||||
|
<Run failures={[]} />
|
||||||
|
</StateCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "tasks-task-chat-run-health",
|
||||||
|
title: "Tasks and conversations/Task creation and lifecycle/Task chat/Run health",
|
||||||
|
component: AllStates,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen",
|
||||||
|
throwPlayFunctionExceptions: true,
|
||||||
|
paperclip: {
|
||||||
|
capabilities: ["tasks/discussion", "runs-adapters/runs"],
|
||||||
|
status: "current",
|
||||||
|
implementation: "production",
|
||||||
|
boundary: "fixtures",
|
||||||
|
notes: "Production runner turn fed the same provider-notice items the transcript adapter builds from a Codex mcpServer/startupStatus/updated failure. No runner, provider or API is involved.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof AllStates>;
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const AllRunHealthStates: Story = {};
|
||||||
|
|
||||||
|
export const InTaskChat: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Task title="Investigate duplicate webhook deliveries to Acme">
|
||||||
|
<Run failures={[PAPERCLIP_FAILED]} />
|
||||||
|
</Task>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const banner = await canvas.findByTestId("task-chat-run-health-banner");
|
||||||
|
await expect(banner).toHaveTextContent("paperclip MCP server failed to start");
|
||||||
|
const toggle = within(banner).getByRole("button");
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await expect(canvas.getByText(/The agent ran without these tools/)).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProvisionalTitleBeforeRename: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Task title={request.text} provisional>
|
||||||
|
<Run failures={[]} />
|
||||||
|
</Task>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mobile: Story = {
|
||||||
|
render: InTaskChat.render,
|
||||||
|
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||||
|
};
|
||||||
Reference in new issue
Block a user