Skip to content

Commit f17e98b

Browse files
authored
Merge pull request #1711 from XiaomiMiMo/fix/actor-notification-wording
fix(inbox): sub-session notification wording reflects reported task status, not just actor lifecycle
2 parents 8f22fa2 + 8d52e22 commit f17e98b

5 files changed

Lines changed: 145 additions & 11 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1533,14 +1533,15 @@ function UserMessage(props: {
15331533
if (s === "completed") return { icon: "✓", fg: theme.success, label: "completed" }
15341534
if (s === "failed") return { icon: "✗", fg: theme.error, label: "failed" }
15351535
if (s === "stalled") return { icon: "⏳", fg: theme.warning, label: "stalled" }
1536+
if (s === "ended") return { icon: "⊙", fg: theme.textMuted, label: "ended" }
15361537
return { icon: "⊜", fg: theme.textMuted, label: "cancelled" }
15371538
})
15381539
return (
15391540
<box id={props.message.id} marginTop={props.index === 0 ? 0 : 1} paddingLeft={2} flexDirection="row" gap={1}>
15401541
<text fg={theme.textMuted}>
15411542
<span style={{ bg: theme.backgroundElement, fg: style().fg, bold: true }}>
15421543
{" "}
1543-
{style().icon} actor {style().label}{" "}
1544+
{style().icon} sub-session {style().label}{" "}
15441545
</span>
15451546
<span style={{ fg: theme.text }}> {note().description}</span>
15461547
<Show when={note().summary}>

packages/opencode/src/inbox/render.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,30 @@ export function renderActorNotification(event: {
2929
// For a stalled notification: how long (ms) since the child's last turn advanced.
3030
stalledForMs?: number
3131
}): string {
32-
const header = `Background actor "${event.description}" (actor_id: ${event.actorID})`
32+
const header = `Background sub-session "${event.description}" (actor_id: ${event.actorID})`
3333
if (event.status === "completed") {
34-
const statusLine = `Status: ${event.reportedStatus ?? "unknown"}`
34+
// event.status is the sub-session *process lifecycle* — it ended cleanly.
35+
// event.reportedStatus is the *task* outcome the sub-session self-reported
36+
// via a `**Status**: ...` header. These are independent: a process can exit
37+
// cleanly while the task failed/blocked. Word the top line by the task
38+
// outcome so we never imply a success the sub-session didn't claim.
39+
const reported = event.reportedStatus?.toLowerCase()
3540
const summaryLine = event.reportedSummary ? `\nSummary: ${event.reportedSummary}` : ""
36-
return `<actor-notification>\n${header} completed.\n${statusLine}${summaryLine}\nResult: ${event.result ?? "(no output)"}\n</actor-notification>`
41+
const resultLine = `\nResult: ${event.result ?? "(no output)"}`
42+
// success/partial (or absent → treat as a plain completion) keep the
43+
// affirmative "completed" verb.
44+
if (!reported || reported === "success" || reported === "partial") {
45+
const statusLine = reported ? `\nStatus: ${reported}` : ""
46+
return `<actor-notification>\n${header} completed.${statusLine}${summaryLine}${resultLine}\n</actor-notification>`
47+
}
48+
// failed/blocked → the sub-session ran to the end but the task did not
49+
// succeed. State the outcome; never say "completed".
50+
if (reported === "failed" || reported === "blocked") {
51+
return `<actor-notification>\n${header} finished (status: ${reported}).${summaryLine}${resultLine}\n</actor-notification>`
52+
}
53+
// Any other reported value = unknown/unrecognized → neutral verb, and omit
54+
// the misleading "Status: unknown" line entirely.
55+
return `<actor-notification>\n${header} ended (status not reported).${summaryLine}${resultLine}\n</actor-notification>`
3756
}
3857
if (event.status === "failed") {
3958
return `<actor-notification>\n${header} failed.\nError: ${event.error ?? "unknown"}\n</actor-notification>`
@@ -49,8 +68,10 @@ export function renderActorNotification(event: {
4968
export type ParsedActorNotification = {
5069
// "stalled" is reserved for a future watchdog-emitted notification;
5170
// renderActorNotification never produces it today (only completed/failed/
52-
// cancelled). The parse + card styling exist ahead of that producer.
53-
status: "completed" | "failed" | "cancelled" | "stalled"
71+
// cancelled lifecycle). The parse + card styling exist ahead of that producer.
72+
// "ended" is the completed-lifecycle case where the sub-session's task
73+
// outcome was not reported — neutral, neither success nor failure.
74+
status: "completed" | "failed" | "cancelled" | "stalled" | "ended"
5475
description: string
5576
summary?: string
5677
}
@@ -61,12 +82,28 @@ export type ParsedActorNotification = {
6182
// Returns null for any text that isn't an actor notification.
6283
export function parseActorNotification(text: string): ParsedActorNotification | null {
6384
if (!text.trimStart().startsWith("<actor-notification>")) return null
64-
const header = text.match(/Background actor "(.*?)" \(actor_id: [^)]*\)\s+(completed|failed|was cancelled|stalled)\b/)
85+
// The verb reflects the *task* outcome, not just the process lifecycle:
86+
// completed → task succeeded / plain completion
87+
// finished (status: failed|blocked) → process ended cleanly, task not ok
88+
// ended (status not reported) → process ended cleanly, outcome unknown
89+
// failed → the process itself failed
90+
// was cancelled / stalled → cancelled / watchdog
91+
const header = text.match(
92+
/Background (?:sub-session|actor) "(.*?)" \(actor_id: [^)]*\)\s+(completed|finished|ended|failed|was cancelled|stalled)\b/,
93+
)
6594
if (!header) return null
6695
const description = header[1]
6796
const verb = header[2]
6897
const status: ParsedActorNotification["status"] =
69-
verb === "completed" ? "completed" : verb === "failed" ? "failed" : verb === "stalled" ? "stalled" : "cancelled"
98+
verb === "completed"
99+
? "completed"
100+
: verb === "finished" || verb === "failed"
101+
? "failed"
102+
: verb === "ended"
103+
? "ended"
104+
: verb === "stalled"
105+
? "stalled"
106+
: "cancelled"
70107
// Prefer the most human-relevant one-liner: Summary > Result > Error.
71108
// renderActorNotification always emits the Summary line before the Result
72109
// line, so restrict the Summary match to the region before the first

packages/opencode/src/tool/actor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ export const ActorTool = Tool.define(
767767
metadata: { sessionId: spawnResult.sessionID, actorId: spawnResult.actorID, model },
768768
output:
769769
(taskNotice ? taskNotice + "\n" : "") +
770-
`Background actor started. actor_id: ${spawnResult.actorID}\nThe result will be delivered as a notification when complete.`,
770+
`Background sub-session started. actor_id: ${spawnResult.actorID}\nThe result will be delivered as a notification when complete.`,
771771
}
772772
}
773773

packages/opencode/test/inbox/parse-actor-notification.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,92 @@ describe("parseActorNotification", () => {
6262
})
6363
})
6464

65+
test("completed lifecycle but reportedStatus=failed reads as failed, never completed", () => {
66+
const text = renderActorNotification({
67+
actorID: "general-4",
68+
description: "Fix the flaky test",
69+
status: "completed",
70+
reportedStatus: "failed",
71+
reportedSummary: "could not reproduce",
72+
result: "full body",
73+
})
74+
// Top line states the outcome and must never imply success.
75+
expect(text).toContain("finished (status: failed)")
76+
expect(text).not.toContain("completed")
77+
expect(parseActorNotification(text)).toEqual({
78+
status: "failed",
79+
description: "Fix the flaky test",
80+
summary: "could not reproduce",
81+
})
82+
})
83+
84+
test("completed lifecycle but reportedStatus=blocked reads as failed", () => {
85+
const text = renderActorNotification({
86+
actorID: "general-5",
87+
description: "Wire up the API",
88+
status: "completed",
89+
reportedStatus: "blocked",
90+
result: "waiting on credentials",
91+
})
92+
expect(text).toContain("finished (status: blocked)")
93+
expect(parseActorNotification(text)).toEqual({
94+
status: "failed",
95+
description: "Wire up the API",
96+
summary: "waiting on credentials",
97+
})
98+
})
99+
100+
test("completed lifecycle with reportedStatus=unknown reads as neutral ended", () => {
101+
const text = renderActorNotification({
102+
actorID: "general-6",
103+
description: "Do the thing",
104+
status: "completed",
105+
reportedStatus: "unknown", // sub-session ran but did not report a task outcome
106+
result: "some output",
107+
})
108+
// Must NOT imply success, and must NOT emit the misleading "Status: unknown".
109+
expect(text).toContain("ended (status not reported)")
110+
expect(text).not.toContain("Status: unknown")
111+
expect(parseActorNotification(text)).toEqual({
112+
status: "ended",
113+
description: "Do the thing",
114+
summary: "some output",
115+
})
116+
})
117+
118+
test("completed lifecycle with no reportedStatus at all reads as a plain completion", () => {
119+
const text = renderActorNotification({
120+
actorID: "general-8",
121+
description: "Plain job",
122+
status: "completed",
123+
result: "done",
124+
})
125+
expect(text).toContain("completed")
126+
expect(text).not.toContain("Status:")
127+
expect(parseActorNotification(text)).toEqual({
128+
status: "completed",
129+
description: "Plain job",
130+
summary: "done",
131+
})
132+
})
133+
134+
test("completed lifecycle with reportedStatus=partial still reads as completed", () => {
135+
const text = renderActorNotification({
136+
actorID: "general-7",
137+
description: "Partial job",
138+
status: "completed",
139+
reportedStatus: "partial",
140+
reportedSummary: "did half",
141+
result: "body",
142+
})
143+
expect(text).toContain("completed")
144+
expect(parseActorNotification(text)).toEqual({
145+
status: "completed",
146+
description: "Partial job",
147+
summary: "did half",
148+
})
149+
})
150+
65151
test("parses a cancelled notification (no summary)", () => {
66152
const text = renderActorNotification({
67153
actorID: "peer-3",
@@ -76,7 +162,7 @@ describe("parseActorNotification", () => {
76162

77163
test("parses a stalled notification (watchdog variant)", () => {
78164
const text =
79-
'<actor-notification>\nBackground actor "Wedged agent" (actor_id: general-7) stalled.\nSummary: no output for 10m\n</actor-notification>'
165+
'<actor-notification>\nBackground sub-session "Wedged agent" (actor_id: general-7) stalled.\nSummary: no output for 10m\n</actor-notification>'
80166
expect(parseActorNotification(text)).toEqual({
81167
status: "stalled",
82168
description: "Wedged agent",
@@ -93,4 +179,14 @@ describe("parseActorNotification", () => {
93179
test("returns null when the wrapper is present but the header is malformed", () => {
94180
expect(parseActorNotification("<actor-notification>\ngarbage\n</actor-notification>")).toBeNull()
95181
})
182+
183+
test("backward compat: parses legacy 'Background actor' format as a card", () => {
184+
const text =
185+
'<actor-notification>\nBackground actor "Legacy task" (actor_id: explore-1) completed.\nResult: done\n</actor-notification>'
186+
expect(parseActorNotification(text)).toEqual({
187+
status: "completed",
188+
description: "Legacy task",
189+
summary: "done",
190+
})
191+
})
96192
})

packages/opencode/test/tool/actor.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ describe("Actor tool task_id degradation", () => {
746746

747747
expect(result.output).toContain("not-a-task")
748748
expect(result.output.toLowerCase()).toContain("ad-hoc")
749-
expect(result.output).toContain("Background actor started")
749+
expect(result.output).toContain("Background sub-session started")
750750
}),
751751
),
752752
)

0 commit comments

Comments
 (0)