feat(native-chat): show Claude's /goal in the goal banner (#28923)

* feat(native-chat): show Claude's /goal in the goal banner

Claude reports a goal only as a synthetic reply in stream-json, so Orca saved it
as assistant text and never showed the goal banner Codex gets. Journal Claude's
goal replies as the typed goal transition, infer completion when Claude ends a
turn on its own, report the goal to clients apart from host goal controls, and
let the banner clear it by sending /goal clear.

* fix(native-chat): keep a Claude goal open when a person stops the turn

A Stop pressed while Claude ran the goal's check ended the turn with a clean
result, which read as the goal being met and removed the goal banner. The
journal's Stop rule already says when a person's Stop decides how a turn
ends; a turn it decides never counts as the goal being met.

* fix(native-chat): count Claude goal time from the turn start and title an idle goal 'Goal set'

* Fix Claude goal clear reveal and result accounting

* Hold Claude goal clear during pending sends

* Preserve Claude goal text with status-like phrases

* Require host goal support for persisted goal banner

* Keep structured session modules within line limits

* test(native-chat): give option-read journal doubles the goal read

* test(claude): give journal doubles the goal source read
This commit is contained in:
Brennan Benson
2026-10-15 13:26:07 -07:00
committed by GitHub
parent 7965ccb222
commit 3a3889c991
42 changed files with 823 additions and 141 deletions
@@ -0,0 +1,26 @@
import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks'
/** Keep live foreground work beside the provider's authoritative background roster. */
export function combineClaudeBackgroundTaskRoster(
prior: ReadonlyMap<string, TrackedClaudeBackgroundTask>,
roster: ReadonlyMap<string, TrackedClaudeBackgroundTask>,
limit: number
): Map<string, TrackedClaudeBackgroundTask> {
const combined = new Map<string, TrackedClaudeBackgroundTask>()
const retainable = [...prior].filter(
([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id)
)
let evict = Math.max(0, roster.size + retainable.length - limit)
// Drop oldest foreground rows first; preserve the provider's roster order.
for (const [id, task] of retainable) {
if (evict > 0) {
evict -= 1
continue
}
combined.set(id, task)
}
for (const [id, task] of roster) {
combined.set(id, task)
}
return combined
}
@@ -530,6 +530,16 @@ describe('ClaudeBackgroundTaskTracker', () => {
)
}
expect(tracker.stoppableTaskIds.length).toBeLessThanOrEqual(256)
expect(tracker.goalBackgroundWork).toBe('live')
tracker.observe(system('task_notification', { task_id: 'unknown', status: 'completed' }))
for (let index = 0; index < 256; index += 1) {
tracker.observe(
system('task_notification', { task_id: `task-${index}`, status: 'completed' })
)
}
expect(tracker.goalBackgroundWork).toBe('unverifiable')
tracker.observe(aggregate([]))
expect(tracker.goalBackgroundWork).toBe('exited')
})
it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => {
@@ -15,9 +15,11 @@ import {
} from './claude-background-task-frames'
import {
ClaudeSettledBackgroundTasks,
claudeBackgroundTaskDetail,
claudeBackgroundTaskDetails,
hasLiveClaudeBackgroundWork,
type TrackedClaudeBackgroundTask
} from './claude-settled-background-tasks'
import { combineClaudeBackgroundTaskRoster } from './claude-background-task-roster-merge'
// `claude-subagent-*` reads this channel through these names; the readers themselves
// live in the frames module so both consumers share one definition.
@@ -30,12 +32,14 @@ export {
export type { ClaudeBackgroundTaskKind } from './claude-background-task-frames'
const MAX_TRACKED_TASKS = 256
export type ClaudeGoalBackgroundWork = 'live' | 'unverifiable' | 'exited'
export class ClaudeBackgroundTaskTracker {
private readonly tasks = new Map<string, TrackedClaudeBackgroundTask>()
private readonly retention = new ClaudeSettledBackgroundTasks()
private readonly terminalTaskIds = new Set<string>()
private aggregateRosterObserved = false
private coverageIncomplete = false
private monitoring = false
private publishedTasksFingerprint = ''
@@ -62,6 +66,14 @@ export class ClaudeBackgroundTaskTracker {
return ids
}
get goalBackgroundWork(): ClaudeGoalBackgroundWork {
return hasLiveClaudeBackgroundWork(this.tasks.values())
? 'live'
: this.coverageIncomplete
? 'unverifiable'
: 'exited'
}
observe(message: Record<string, unknown>, startsTurn = false): boolean {
// Background work publishes through a foreground turn: the strip stays
// honest mid-fan-out and the client alone decides when the idle-only
@@ -91,6 +103,7 @@ export class ClaudeBackgroundTaskTracker {
this.retention.clear()
this.terminalTaskIds.clear()
this.aggregateRosterObserved = false
this.coverageIncomplete = false
return this.refreshMonitoring()
}
@@ -210,22 +223,30 @@ export class ClaudeBackgroundTaskTracker {
private replaceAggregateRoster(value: unknown): void {
if (!Array.isArray(value)) {
this.coverageIncomplete = true
return
}
const prior = new Map(this.tasks)
this.aggregateRosterObserved = true
this.tasks.clear()
const roster = new Map<string, TrackedClaudeBackgroundTask>()
let coverageIncomplete = false
for (const valueTask of value) {
if (roster.size >= MAX_TRACKED_TASKS) {
break
}
const task = record(valueTask)
if (!task || task.ambient === true) {
if (!task) {
coverageIncomplete = true
continue
}
if (task.ambient === true) {
continue
}
const id = taskId(task)
if (!id) {
coverageIncomplete = true
continue
}
if (!roster.has(id) && roster.size >= MAX_TRACKED_TASKS) {
coverageIncomplete = true
continue
}
// An authoritative live roster supersedes an earlier terminal edge — for
@@ -246,26 +267,8 @@ export class ClaudeBackgroundTaskTracker {
totalTokens: existing?.totalTokens
})
}
// Live foreground work is not in a BACKGROUND roster and is not superseded
// by one. Budget counted up front so eviction drops the STALEST retained
// rows rather than the newest, and roster entries are never starved.
const retainable = [...prior].filter(
([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id)
)
let evict = Math.max(0, roster.size + retainable.length - MAX_TRACKED_TASKS)
// Retained rows keep their own relative order and stay ahead of the roster,
// so a live row the user is reading does not drop below it when a roster
// frame lands. Within the roster the PROVIDER's order wins — including for
// a task it reports live again, which belongs where the provider lists it
// rather than appended after the rows that outlived it.
for (const [id, task] of retainable) {
if (evict > 0) {
evict -= 1
continue
}
this.tasks.set(id, task)
}
for (const [id, task] of roster) {
this.coverageIncomplete = coverageIncomplete
for (const [id, task] of combineClaudeBackgroundTaskRoster(prior, roster, MAX_TRACKED_TASKS)) {
this.tasks.set(id, task)
}
for (const [id, task] of prior) {
@@ -285,6 +288,9 @@ export class ClaudeBackgroundTaskTracker {
}
}
if (!foregroundId) {
if (task.backgrounded) {
this.coverageIncomplete = true
}
return
}
this.tasks.delete(foregroundId)
@@ -336,13 +342,6 @@ export class ClaudeBackgroundTaskTracker {
}
private backgroundTaskDetails(): AgentSessionBackgroundTask[] {
const details: AgentSessionBackgroundTask[] = []
for (const [id, task] of this.tasks) {
if (!task.backgrounded && !task.liveInTurn) {
continue
}
details.push(claudeBackgroundTaskDetail(id, task))
}
return details
return claudeBackgroundTaskDetails(this.tasks)
}
}
+7 -1
View File
@@ -34,6 +34,7 @@ import type { ClaudeSubagentIdentities } from './claude-subagent-identities'
import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening'
import type { ClaudeOpenTurn } from './claude-open-turn'
import type { ClaudeAuthenticationFailures } from './claude-authentication-failures'
import type { ClaudeThreadGoal } from './claude-thread-goal'
export type ClaudeMessageJournalContext = {
sink: StructuredAgentSessionEventSink
@@ -52,6 +53,7 @@ export type ClaudeMessageJournalContext = {
* latch; this module asks it rather than tracking a copy. */
turn: ClaudeOpenTurn
authenticationFailures: ClaudeAuthenticationFailures
threadGoal: ClaudeThreadGoal
}
export function journalClaudeMessage(
@@ -89,7 +91,11 @@ export function journalClaudeMessage(
: envelope.parentToolUseId
const stamp = ctx.corrections.stampFor(producerRef)
const outputEnvelope = claudeOutputEnvelope(envelope)
const body = ctx.authenticationFailures.assistant(message) ?? claudeMessageBody(outputEnvelope)
ctx.threadGoal.observe(message)
const body =
ctx.authenticationFailures.assistant(message) ??
ctx.threadGoal.report(message, claudeMessageIdentity(envelope), observedAt) ??
claudeMessageBody(outputEnvelope)
const identity =
(body && envelope.role === 'assistant'
? ctx.streamedBlocks.reconcile(envelope)?.identity
+5
View File
@@ -80,6 +80,11 @@ export class ClaudeOpenTurn {
: AGENT_JOURNAL_THREAD_SCOPE
}
/** When the open turn started, or null when none is open. */
get startedAt(): number | null {
return this.current?.startedAt ?? null
}
get isOpen(): boolean {
return this.current !== null
}
+20 -1
View File
@@ -15,6 +15,7 @@ import {
} from './claude-structured-provider-fallback'
import { claudeTurnEndForResult } from './claude-turn-lifecycle-item'
import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening'
import type { ClaudeGoalBackgroundWork } from './claude-background-task-tracker'
export type ClaudeResultJournalContext = Pick<
ClaudeMessageJournalContext,
@@ -25,9 +26,11 @@ export type ClaudeResultJournalContext = Pick<
| 'corrections'
| 'turn'
| 'authenticationFailures'
| 'threadGoal'
> & {
prompts: ClaudeJournalPrompts
context: ClaudeContextFacts
goalBackgroundWork: () => ClaudeGoalBackgroundWork
}
export function journalClaudeResult(
@@ -40,7 +43,9 @@ export function journalClaudeResult(
turn,
prompts,
context,
authenticationFailures
authenticationFailures,
threadGoal,
goalBackgroundWork
}: ClaudeResultJournalContext,
message: Record<string, unknown>,
observedAt: number
@@ -56,6 +61,7 @@ export function journalClaudeResult(
}
// Read before the settle below closes it: the result reports that turn's end.
const endedTurnScope = turn.turnScope
const endedTurnStartedAt = turn.startedAt
// Read before the settle below closes the turn: an error end the journal's Stop rule makes a
// person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`).
const turnId = settlesTurn ? turn.id : null
@@ -71,6 +77,19 @@ export function journalClaudeResult(
streamedBlocks.clear()
streamedText.settle()
}
const goal = threadGoal.settle(
message,
{
backgroundWork: goalBackgroundWork(),
stoppedByPerson: leftToStop,
turnStartedAt: endedTurnStartedAt
},
observedAt
)
if (goal) {
sink.appendItem(goal.identity, goal.body, { turnScope: endedTurnScope })
sink.publish()
}
const kind = claudeProviderFrameKind(message)
const failure = claudeResultFailure(message, leftToStop)
const alreadyShown = authenticationFailures.resultAlreadyShown(message)
@@ -48,6 +48,27 @@ export function claudeBackgroundTaskDetail(
}
}
export function claudeBackgroundTaskDetails(
tasks: ReadonlyMap<string, TrackedClaudeBackgroundTask>
): AgentSessionBackgroundTask[] {
const details: AgentSessionBackgroundTask[] = []
for (const [id, task] of tasks) {
if (task.backgrounded || task.liveInTurn) {
details.push(claudeBackgroundTaskDetail(id, task))
}
}
return details
}
export function hasLiveClaudeBackgroundWork(tasks: Iterable<TrackedClaudeBackgroundTask>): boolean {
for (const task of tasks) {
if (task.backgrounded) {
return true
}
}
return false
}
function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
map.delete(key)
map.set(key, value)
@@ -163,6 +163,7 @@ function persistedTarget(
persisted.forEach((body, itemId) => visit(itemId, 0, body, {}))
}) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'],
itemBody: (itemId: string) => persisted.get(itemId) ?? null,
threadGoalSource: () => ({ goal: null, row: null }),
epoch: 'test'
}) as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
@@ -35,6 +35,8 @@ import { claudeChildToolQueries } from './claude-child-tool-queries'
import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling'
import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling'
import { ClaudeAuthenticationFailures } from './claude-authentication-failures'
import { ClaudeThreadGoal } from './claude-thread-goal'
import type { ClaudeGoalBackgroundWork } from './claude-background-task-tracker'
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
export type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
@@ -47,6 +49,7 @@ export type ClaudeJournalTranslatorDeps = {
fallbackIdPrefix?: string
onBackgroundTaskJournalFailure?: (error: Error) => void
account?: () => AgentSessionAccountKind | undefined
goalBackgroundWork?: () => ClaudeGoalBackgroundWork
}
export function createClaudeJournalTranslator(
@@ -167,10 +170,16 @@ export function createClaudeJournalTranslator(
providerFallback,
corrections,
turn,
authenticationFailures: new ClaudeAuthenticationFailures(deps.account)
authenticationFailures: new ClaudeAuthenticationFailures(deps.account),
threadGoal: new ClaudeThreadGoal(() => deps.sink.journalLinkage?.()?.threadGoalSource())
}
const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context }
const resultContext: ClaudeResultJournalContext = {
...messageContext,
prompts,
context,
goalBackgroundWork: deps.goalBackgroundWork ?? (() => 'unverifiable')
}
const handleMessage = (
message: Record<string, unknown>,
@@ -70,12 +70,20 @@ export async function acquireClaudeSession({
// Frames are admitted only after launch resolution proves the provider session
// this acquisition owns. Keep the check ahead of every stateful consumer.
const initProof = createClaudeInitProof()
const translator = createClaudeSessionJournalTranslator(input.events, String(input.fence), {
attempt,
initProof,
callbacks,
sessionId
})
const translator = createClaudeSessionJournalTranslator(
input.events,
String(input.fence),
{
attempt,
initProof,
callbacks,
sessionId
},
() =>
liveSession && sessions.get(sessionId) === liveSession
? liveSession.backgroundTasks.goalBackgroundWork
: 'unverifiable'
)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
@@ -6,6 +6,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import type { ClaudeGoalBackgroundWork } from './claude-background-task-tracker'
import type { ClaudeInitProof } from './claude-structured-init-proof'
import type {
ClaudeAcquisitionAttempt,
@@ -15,7 +16,8 @@ import type {
export function createClaudeSessionJournalTranslator(
sink: StructuredAgentSessionEventSink | undefined,
fallbackIdPrefix: string,
failure: Parameters<typeof createClaudeJournalFailureHandler>[0]
failure: Parameters<typeof createClaudeJournalFailureHandler>[0],
goalBackgroundWork: () => ClaudeGoalBackgroundWork
): ClaudeJournalTranslator | null {
const { attempt } = failure
return sink
@@ -23,6 +25,7 @@ export function createClaudeSessionJournalTranslator(
sink,
account: () => attempt.account,
fallbackIdPrefix,
goalBackgroundWork,
onBackgroundTaskJournalFailure: createClaudeJournalFailureHandler(failure),
bindPromptItemId: (itemId, promptKey) =>
attempt.prompts.bindJournalItemId(itemId, promptKey)
@@ -68,6 +68,8 @@ function persistedTarget(
persisted.forEach((body, itemId) => visit(itemId, 0, body, {}))
}) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'],
itemBody: (itemId: string) => persisted.get(itemId) ?? null,
threadGoal: () => null,
threadGoalSource: () => ({ goal: null, row: null }),
epoch: 'test'
}) as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
@@ -0,0 +1,223 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalThreadGoal
} from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture'
// Frame shapes captured from Claude Code 2.1.280 stream-json: `/goal` answers with a root
// `<synthetic>` assistant reply, and a met goal ends the turn with no goal frame of its own.
const OBJECTIVE = 'reply with exactly the word done'
function goalSink(
journalGoal: AgentJournalThreadGoal | null = null,
journalRow?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
) {
const rows: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
let stopped = false
const backgroundTasks = new ClaudeBackgroundTaskTracker()
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => rows.push({ identity, body }),
appendTombstone: vi.fn(),
publish: vi.fn(),
journalStopDecidesTurn: () => stopped,
journalLinkage: () => ({
epoch: 'test',
visitItemsWithLinkage: () => {},
threadGoal: () => journalGoal,
threadGoalSource: () => ({
goal: journalGoal,
row:
journalRow?.body.kind === 'status'
? { identity: journalRow.identity, text: journalRow.body.text }
: null
})
})
}
const translator = createClaudeJournalTranslator({
sink,
coalesceMs: 0,
goalBackgroundWork: () => backgroundTasks.goalBackgroundWork
})
let at = 1_000
const frame = (message: Record<string, unknown>, elapsedMs = 1_000) => {
at += elapsedMs
backgroundTasks.observe(message)
translator.handle({
type: 'message',
sessionId: 'orca-session',
observedAt: at,
message: { session_id: 'claude-session', parent_tool_use_id: null, ...message }
})
}
const reply = (uuid: string, model: string, text: string) =>
frame({
type: 'assistant',
uuid,
message: { role: 'assistant', model, content: [{ type: 'text', text }] }
})
const result = (uuid: string, fields: Record<string, unknown>, elapsedMs?: number) =>
frame({ type: 'result', uuid, ...fields }, elapsedMs)
const goals = () =>
rows.flatMap(({ identity, body }) =>
body.kind === 'status' && body.threadGoal ? [{ identity, body }] : []
)
const savedGoal = () => {
const body = goals().at(-1)?.body
return body?.kind === 'status' && body.threadGoal?.state === 'set' ? body.threadGoal.goal : null
}
const pressStop = () => {
stopped = true
}
return { frame, reply, result, goals, savedGoal, pressStop }
}
describe('Claude /goal journaling', () => {
it('journals the goal Claude reports, its time, and its completion when Claude ends on its own', () => {
const { reply, result, goals } = goalSink()
reply('goal-set', '<synthetic>', `Goal set: ${OBJECTIVE}`)
reply('work-1', 'claude-haiku-4-5', 'Working on it.')
// A turn cap stops Claude before the goal passes: the goal stays open and gains the time.
result(
'result-1',
{ subtype: 'error_max_turns', is_error: true, terminal_reason: 'max_turns' },
5_000
)
reply('work-2', 'claude-haiku-4-5', 'done')
result('result-2', { subtype: 'success', is_error: false, terminal_reason: 'completed' }, 3_000)
expect(goals()).toEqual([
{
identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' },
body: expect.objectContaining({
text: `Goal set: ${OBJECTIVE}`,
threadGoal: {
state: 'set',
goal: expect.objectContaining({
objective: OBJECTIVE,
status: 'active',
timeUsedSeconds: 0
})
}
})
},
{
identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' },
body: expect.objectContaining({
text: `Goal set: ${OBJECTIVE}`,
threadGoal: {
state: 'set',
goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 6 })
}
})
},
{
identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'result-2:goal' },
body: expect.objectContaining({
text: `Goal complete: ${OBJECTIVE}`,
threadGoal: {
state: 'set',
goal: expect.objectContaining({ status: 'complete', timeUsedSeconds: 10 })
}
})
}
])
})
// Seen live: a Stop pressed while Claude ran the goal's check ended the turn with a clean result.
it('keeps the goal open when a person stops the turn, even on a clean result', () => {
const { reply, result, goals, pressStop } = goalSink()
reply('goal-set', '<synthetic>', `Goal set: ${OBJECTIVE}`)
reply('work', 'claude-opus-5-5', 'Still waiting.')
pressStop()
result('result', { subtype: 'success', is_error: false, terminal_reason: 'completed' })
expect(goals().map(({ body }) => body.kind === 'status' && body.text)).toEqual([
`Goal set: ${OBJECTIVE}`,
`Goal set: ${OBJECTIVE}`
])
expect(goals().at(-1)?.body).toMatchObject({ threadGoal: { goal: { status: 'active' } } })
})
it('keeps the goal active when a captured background Agent outlives a clean root result', () => {
const { frame, reply, goals } = goalSink()
reply('goal-set', '<synthetic>', `Goal set: ${OBJECTIVE}`)
let previousAt = 0
for (const [index, captured] of MOVED_TO_BACKGROUND.entries()) {
frame({ uuid: `capture-${index}`, ...captured.frame }, captured.at - previousAt)
previousAt = captured.at
if (captured.frame.type === 'result') {
break
}
}
expect(goals().at(-1)?.body).toMatchObject({
threadGoal: { goal: { status: 'active' } }
})
expect(
goals().some(({ body }) => body.kind === 'status' && body.text.startsWith('Goal complete'))
).toBe(false)
})
it('saves elapsed goal time after restart when a person stops the turn', () => {
const beforeRestart = goalSink()
beforeRestart.reply('goal-set', '<synthetic>', `Goal set: ${OBJECTIVE}`)
const afterRestart = goalSink(beforeRestart.savedGoal(), beforeRestart.goals().at(-1))
afterRestart.reply('work', 'claude-opus-5-5', 'Still waiting.')
afterRestart.pressStop()
afterRestart.result(
'stopped',
{ subtype: 'success', is_error: false, terminal_reason: 'completed' },
5_000
)
expect(afterRestart.goals()).toEqual([
{
identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' },
body: expect.objectContaining({
text: `Goal set: ${OBJECTIVE}`,
threadGoal: {
state: 'set',
goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 5 })
}
})
}
])
const reloaded = goalSink(afterRestart.savedGoal(), afterRestart.goals().at(-1))
reloaded.reply('status', '<synthetic>', `Goal active: ${OBJECTIVE} (not yet evaluated)`)
expect(reloaded.goals()[0]?.body).toMatchObject({
threadGoal: { goal: { status: 'active', timeUsedSeconds: 5 } }
})
})
it('keeps a goal set before a restart open through a status reply, and clears it on /goal clear', () => {
const objective = 'Ship (2 turns) then go'
const { reply, result, goals } = goalSink({
objective,
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 30,
createdAt: 1,
updatedAt: 1
})
// A bare `/goal` runs no model turn, so its clean end says nothing about the goal.
reply('status', '<synthetic>', `Goal active: ${objective} (not yet evaluated)`)
result('status-result', { subtype: 'success', is_error: false, num_turns: 0 })
reply('clear', '<synthetic>', `Goal cleared: ${objective}`)
expect(goals().map(({ body }) => body.kind === 'status' && body.threadGoal)).toEqual([
{
state: 'set',
goal: expect.objectContaining({ objective, status: 'active', timeUsedSeconds: 30 })
},
{ state: 'cleared' }
])
})
})
+203
View File
@@ -0,0 +1,203 @@
// Claude's `/goal`, read into the journal's typed goal transition so readers see it as they see
// Codex's. Stream-json reports a goal only in words: a root `<synthetic>` reply to the command
// ("Goal set: …", "Goal active: … (2 turns)", "Goal cleared: …", "No goal set"). Its
// `active_goal` event never reaches stream-json, and meeting the goal sends no frame at all.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalThreadGoal
} from '../../shared/agent-session-journal-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import { isRootClaudeFrame } from './claude-turn-opening'
import type { ClaudeGoalBackgroundWork } from './claude-background-task-tracker'
const GOAL_SET = 'Goal set: '
const GOAL_ACTIVE = /^Goal active: ([\s\S]+) \((?:not yet evaluated|\d+ turns?)\)$/u
type GoalStatusBody = Extract<AgentJournalItemBody, { kind: 'status' }>
type GoalReport = { kind: 'set'; objective: string } | { kind: 'cleared' }
function syntheticReplyText(message: Record<string, unknown>): string | null {
const reply = claudeRecord(message.message)
if (
message.type !== 'assistant' ||
!isRootClaudeFrame(message) ||
reply?.model !== '<synthetic>'
) {
return null
}
const content = Array.isArray(reply.content) ? reply.content : []
return content
.flatMap((part) => {
const block = claudeRecord(part)
return block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []
})
.join('')
.trim()
}
function goalReport(text: string): GoalReport | null {
if (text.startsWith(GOAL_SET)) {
const objective = text.slice(GOAL_SET.length).trim()
return objective ? { kind: 'set', objective } : null
}
// Also "Goal cleared after an unrecoverable error (…)", which the CLI sends on its own.
if (text.startsWith('Goal cleared') || text.startsWith('No goal set')) {
return { kind: 'cleared' }
}
const active = GOAL_ACTIVE.exec(text)?.[1]
return active ? { kind: 'set', objective: active } : null
}
function goalRow(text: string, goal: AgentJournalThreadGoal | null): GoalStatusBody {
return {
kind: 'status',
text,
threadGoal: goal
? {
state: 'set',
goal: {
...goal,
objective: boundInlineText(goal.objective, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
}
}
: { state: 'cleared' }
}
}
type GoalRowWrite = { identity: AgentJournalItemIdentity; body: GoalStatusBody }
type SavedGoal = {
goal: AgentJournalThreadGoal | null
row: { identity: AgentJournalItemIdentity; text: string } | null
}
export class ClaudeThreadGoal {
/** Undefined until first needed: the journal answers for goals set before this process. */
private known: AgentJournalThreadGoal | null | undefined
/** The row this process wrote for the goal, which a turn's time revises in place. */
private row: { identity: AgentJournalItemIdentity; text: string } | null = null
/** Whether the model worked toward the active goal in the open turn. */
private modelWorked = false
/** `journalGoal` answers undefined until the sink is bound to its journal. */
constructor(private readonly journalGoal: () => SavedGoal | undefined) {}
private get current(): AgentJournalThreadGoal | null {
if (this.known === undefined) {
const saved = this.journalGoal()
if (saved) {
this.known = saved.goal
this.row = saved.row?.identity.provider === 'claude' ? saved.row : null
}
}
return this.known ?? null
}
/** The goal row a root reply to `/goal` becomes, in place of assistant prose; null otherwise. */
report(
message: Record<string, unknown>,
identity: AgentJournalItemIdentity,
observedAt: number
): GoalStatusBody | null {
const text = syntheticReplyText(message)
const report = text ? goalReport(text) : null
if (!text || !report) {
return null
}
const current = this.current
const next: AgentJournalThreadGoal | null =
report.kind === 'cleared'
? null
: current?.status === 'active' && current.objective === report.objective
? current
: {
objective: report.objective,
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: observedAt,
updatedAt: observedAt
}
this.known = next
this.row = next ? { identity, text } : null
this.modelWorked = false
return goalRow(text, next)
}
/** Root model output: a turn the goal's Stop hook will judge before Claude ends it. */
observe(message: Record<string, unknown>): void {
const reply = claudeRecord(message.message)
if (
message.type === 'assistant' &&
isRootClaudeFrame(message) &&
reply?.model !== '<synthetic>' &&
this.current?.status === 'active'
) {
this.modelWorked = true
}
}
/**
* What a root result writes for the goal, or null. With a goal set, Claude ends a turn on its
* own only once the goal's Stop hook passes, so a clean end after model work means the goal was
* met. It defers that check while background work runs, and a person's Stop during the check
* still ends the turn cleanly. Any of those, or any other end (a turn cap, an error), leaves
* the goal open: its row only gains the turn's time, counted as the banner counts it, from the
* later of the goal's last update and the turn's start.
*/
settle(
result: Record<string, unknown>,
end: {
backgroundWork: ClaudeGoalBackgroundWork
stoppedByPerson: boolean
turnStartedAt: number | null
},
observedAt: number
): GoalRowWrite | null {
// A subagent's result ends no turn.
if (!isRootClaudeFrame(result)) {
return null
}
const goal = this.current
const worked = this.modelWorked
this.modelWorked = false
// A command answered with no model turn (a bare `/goal`) spent no time on the goal.
if (goal?.status !== 'active' || (!worked && result.num_turns === 0)) {
return null
}
const since = Math.max(goal.updatedAt, end.turnStartedAt ?? goal.updatedAt)
const sessionId = claudeText(result.session_id)
const uuid = claudeText(result.uuid)
const met =
worked &&
result.subtype === 'success' &&
result.is_error !== true &&
(result.terminal_reason === undefined || result.terminal_reason === 'completed') &&
end.backgroundWork === 'exited' &&
!end.stoppedByPerson
const next: AgentJournalThreadGoal = {
...goal,
status: met ? 'complete' : 'active',
timeUsedSeconds: goal.timeUsedSeconds + Math.max(0, Math.floor((observedAt - since) / 1000)),
updatedAt: observedAt
}
this.known = next
if (met) {
this.row = null
return sessionId && uuid
? {
identity: { provider: 'claude', sessionId, uuid: `${uuid}:goal` },
body: goalRow(`Goal complete: ${goal.objective}`, next)
}
: null
}
// A restart recovers the original identity with the goal, so this revision stays in place.
return this.row ? { identity: this.row.identity, body: goalRow(this.row.text, next) } : null
}
}
@@ -1,4 +1,4 @@
import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-wire'
import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-thread-goal-wire'
import { isCodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
@@ -16,7 +16,6 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-session-thread-goal'
import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage'
import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage'
import {
@@ -74,6 +73,7 @@ import type { JournalStepWriter } from './journal-step-writer'
import type { JournalStopMarks } from './journal-stop-marks'
import type { JournalContextController } from './journal-context-controller'
import type { JournalStopAcceptor } from './journal-stop-acceptance'
import { journalThreadGoalSource, type JournalThreadGoalSource } from './journal-thread-goal-source'
export { AgentSessionJournalError } from './journal-write-guards'
@@ -235,9 +235,12 @@ export class AgentSessionJournal {
newestTurn = (): AgentJournalTurnLifecycle | null =>
newestStructuredAgentSessionTurnBySequence(this.state.items.values())
/** The latest goal and its row, for a provider revising accounting after restart. */
threadGoalSource = (): JournalThreadGoalSource =>
journalThreadGoalSource(this.state.items.values())
/** The latest goal the whole journal records, not only a client's loaded page. */
threadGoal = (): AgentJournalThreadGoal | null =>
currentAgentSessionThreadGoalBySequence(this.state.items.values()) ?? null
threadGoal = (): AgentJournalThreadGoal | null => this.threadGoalSource().goal
/** The newest context facts the whole journal records, not only a client's loaded page. */
contextUsage = (): AgentSessionContextUsage =>
@@ -0,0 +1,27 @@
import type {
AgentJournalItemIdentity,
AgentJournalRenderItem,
AgentJournalThreadGoal
} from '../../../shared/agent-session-journal-types'
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { currentAgentSessionThreadGoalSourceBySequence } from '../../../shared/agent-session-thread-goal'
export type JournalThreadGoalSource = {
goal: AgentJournalThreadGoal | null
row: { identity: AgentJournalItemIdentity; text: string } | null
}
export function journalThreadGoalSource(
items: Iterable<AgentJournalRenderItem>
): JournalThreadGoalSource {
const source = currentAgentSessionThreadGoalSourceBySequence(items)
const item = source?.item
const identity = item ? parseAgentJournalItemKey(item.itemId) : null
return {
goal: source?.goal ?? null,
row:
source?.goal && item?.body.kind === 'status' && identity
? { identity, text: item.body.text }
: null
}
}
@@ -37,9 +37,9 @@ import type {
import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands'
import type {
AgentSessionOptionsResult,
AgentSessionSlashCommand,
AgentSessionThreadGoalChange
AgentSessionSlashCommand
} from '../../../shared/agent-session-wire'
import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire'
import type { AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals'
import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
import type {
@@ -71,7 +71,7 @@ export type StructuredAgentSessionRevisionJournal = Pick<
/** Rows already journaled and who produced each, read by a producer that has to agree with them. */
export type StructuredAgentSessionLinkageJournal = Pick<
AgentSessionJournal,
'epoch' | 'visitItemsWithLinkage'
'epoch' | 'visitItemsWithLinkage' | 'threadGoal' | 'threadGoalSource'
>
/** The row a revision rewrites and its whole new body, read from the journal at execution. */
@@ -14,10 +14,12 @@ import type {
AgentSessionMutationResult,
AgentSessionOptionResult,
AgentSessionPromptResult,
AgentSessionSendResult,
AgentSessionSendResult
} from '../../../shared/agent-session-wire'
import type {
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../shared/agent-session-wire'
} from '../../../shared/agent-session-thread-goal-wire'
import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt'
import { threadGoalPlan } from './structured-agent-session-thread-goal'
import {
@@ -40,7 +40,7 @@ describe('options at rest', () => {
const resting = {
child: null,
params: { provider: 'codex' },
journal: { context: { floor: () => null } }
journal: { threadGoal: () => null, context: { floor: () => null } }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
const context = {
@@ -124,7 +124,7 @@ describe('live Codex option reads', () => {
const live = {
child: { fence: 7 },
params: { provider: 'codex' },
journal: { context: { floor: () => null } }
journal: { threadGoal: () => null, context: { floor: () => null } }
}
const failure = new Error('model/list unavailable')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields.
@@ -162,7 +162,7 @@ describe('live Codex option reads', () => {
const live = {
child: { fence: 7 },
params: { provider: 'codex' },
journal: { context: { floor: () => null } }
journal: { threadGoal: () => null, context: { floor: () => null } }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields.
const context = {
@@ -12,6 +12,10 @@ import {
type AgentSessionOptionsResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentJournalCursor,
AgentJournalThreadGoal
} from '../../../shared/agent-session-journal-types'
import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec'
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
@@ -255,11 +259,25 @@ export async function readStructuredAgentSessionOptions(
conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'],
// A live child may narrow what its agent declares, never widen it.
acceptsImages: capabilities?.imagePrompts === true && options.acceptsImages !== false,
...(capabilities?.threadGoal
? { threadGoal: { current: session.journal.threadGoal(), ...contextFloor } }
: {}),
...structuredAgentSessionThreadGoalReport(
capabilities?.threadGoal === true,
session.journal.threadGoal(),
contextFloor
),
...(capabilities?.contextUsage
? { contextUsage: { current: session.journal.contextUsage(), ...contextFloor } }
: {})
}
}
/** A goal the host can change, or one only the agent's own `/goal` changes, reported apart. */
function structuredAgentSessionThreadGoalReport(
hostChangesGoal: boolean,
current: AgentJournalThreadGoal | null,
contextFloor: { contextFloor?: AgentJournalCursor }
): Pick<AgentSessionOptionsResult, 'threadGoal' | 'reportedThreadGoal'> {
if (hostChangesGoal) {
return { threadGoal: { current, ...contextFloor } }
}
return current ? { reportedThreadGoal: { current, ...contextFloor } } : {}
}
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry'
import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire'
import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
@@ -6,11 +6,11 @@ import type {
AgentJournalItemIdentity,
AgentJournalThreadGoal
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
import type {
AgentSessionMutationEnvelope,
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../shared/agent-session-wire'
} from '../../../shared/agent-session-thread-goal-wire'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type {
@@ -59,7 +59,7 @@ function goal(status: AgentJournalThreadGoal['status']): AgentJournalThreadGoal
}
}
function sessionView(): React.JSX.Element {
function sessionView(agent: 'codex' | 'claude' = 'codex'): React.JSX.Element {
return (
<TooltipProvider>
<NativeChatStructuredSession
@@ -68,7 +68,7 @@ function sessionView(): React.JSX.Element {
tabId="structured-tab-goal"
sessionId="session-goal"
target={{ kind: 'local' }}
agent="codex"
agent={agent}
/>
</TooltipProvider>
)
@@ -77,7 +77,7 @@ function sessionView(): React.JSX.Element {
function showStripAndGoal(status: AgentJournalThreadGoal['status'] = 'active'): void {
mocks.monitoringBackgroundTasks = true
mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
mocks.threadGoal = { goal: goal(status), pending: false, change: vi.fn() }
mocks.threadGoal = { goal: goal(status), pending: false, hostChanges: true, change: vi.fn() }
}
describe('NativeChatStructuredSession task strip on the goal tab', () => {
@@ -87,6 +87,40 @@ describe('NativeChatStructuredSession task strip on the goal tab', () => {
resetStructuredSessionMocks()
})
it('reveals a Claude goal cleared from the strip while preserving the composer draft', () => {
const change = vi.fn(async () => true)
mocks.threadGoal = { goal: goal('active'), pending: false, hostChanges: false, change }
const view = render(sessionView('claude'))
const composer = screen.getByRole('textbox', { name: 'Message' })
fireEvent.change(composer, { target: { value: 'Keep this draft' } })
const clear = screen.getByRole('button', { name: 'Clear goal' })
mocks.sendOut = true
view.rerender(sessionView('claude'))
expect(clear).toHaveProperty('disabled', true)
fireEvent.click(clear)
expect(change).not.toHaveBeenCalled()
expect(mocks.revealLatest).not.toHaveBeenCalled()
mocks.sendOut = false
mocks.isWorking = true
view.rerender(sessionView('claude'))
expect(mocks.turnId).toBeNull()
expect(clear).toHaveProperty('disabled', true)
fireEvent.click(clear)
expect(change).not.toHaveBeenCalled()
expect(mocks.revealLatest).not.toHaveBeenCalled()
mocks.isWorking = false
view.rerender(sessionView('claude'))
expect(clear).toHaveProperty('disabled', false)
fireEvent.click(clear)
expect(change).toHaveBeenCalledWith({ kind: 'clear' })
expect(mocks.revealLatest).toHaveBeenCalledOnce()
expect(composer).toHaveProperty('value', 'Keep this draft')
})
it('stacks the strip directly on the goal tab at the tab width, sharing one edge', () => {
showStripAndGoal()
render(sessionView())
@@ -191,6 +191,7 @@ afterEach(() => {
updatedAt: 1
},
pending: false,
hostChanges: true,
change: vi.fn()
}
mocks.queuedCards = [
@@ -1,4 +1,3 @@
import { act } from '@testing-library/react'
import { useImperativeHandle } from 'react'
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
@@ -50,12 +49,6 @@ export function useProbeClock(): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
}
export async function advanceProbeClock(milliseconds: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(milliseconds)
})
}
export function seededEntry(
sessionId: string,
clientMessageId: string,
@@ -156,6 +149,7 @@ export function createStructuredSessionMocks() {
monitoringBackgroundTasks: false,
showBackgroundTasks: false,
isWorking: false,
sendOut: false,
turnId: null as string | null,
// Unset: Stop follows the turn, as against an older host.
canStop: nullable<boolean>(),
@@ -227,6 +221,7 @@ export function createStructuredSessionMocks() {
submissions: mocks.submissions,
send: mocks.send,
isWorking: mocks.isWorking,
sendOut: mocks.sendOut,
backgroundTasks: {
show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks,
isMonitoring: mocks.monitoringBackgroundTasks,
@@ -393,6 +388,7 @@ export function createStructuredSessionMocks() {
mocks.monitoringBackgroundTasks = false
mocks.showBackgroundTasks = false
mocks.isWorking = false
mocks.sendOut = false
mocks.turnId = null
mocks.canStop = null
mocks.stopPressed = false
@@ -345,13 +345,20 @@ export function NativeChatStructuredSession(
key={props.sessionId}
goal={controller.threadGoal.goal}
pending={controller.threadGoal.pending}
hostChanges={controller.threadGoal.hostChanges}
sendUnavailable={structuredTransport.sendOut || controller.isWorking}
isVisible={props.isVisible}
runningTurn={
controller.turnId === null
? null
: { startedAt: controller.workingStartedAt ?? null }
}
onChange={(change) => void controller.threadGoal?.change(change)}
onChange={(change) => {
if (change.kind === 'clear' && !controller.threadGoal?.hostChanges) {
structuredTransport.onSubmitted?.()
}
void controller.threadGoal?.change(change)
}}
/>
) : null}
{/* Prompt cards take the composer's slot, below the background-task dock. */}
@@ -38,6 +38,8 @@ function renderBanner(
<NativeChatThreadGoalBanner
goal={value}
pending={pending}
hostChanges
sendUnavailable={false}
isVisible
runningTurn={runningTurn}
onChange={onChange}
@@ -68,6 +70,7 @@ describe('NativeChatThreadGoalBanner', () => {
it('shows the reported time for an active goal while no turn runs', () => {
vi.useFakeTimers({ now: NOW })
renderBanner(goal(), false, null)
expect(screen.getByText('Goal set')).toBeInTheDocument()
expect(screen.getByText('• 1m 0s')).toBeInTheDocument()
})
@@ -10,7 +10,7 @@ import {
agentSessionThreadGoalElapsedSeconds,
agentSessionThreadGoalStatusChange
} from '../../../../shared/agent-session-thread-goal'
import type { AgentSessionThreadGoalChange } from '../../../../shared/agent-session-wire'
import type { AgentSessionThreadGoalChange } from '../../../../shared/agent-session-thread-goal-wire'
import {
formatNativeChatThreadGoalElapsed,
nativeChatThreadGoalStatusLabel
@@ -47,6 +47,10 @@ function GoalAction(props: {
export function NativeChatThreadGoalBanner(props: {
goal: AgentJournalThreadGoal
pending: boolean
/** False where only the agent's own `/goal` changes the goal, which has no pause. */
hostChanges: boolean
/** The composer cannot admit another message or the session is working. */
sendUnavailable: boolean
isVisible: boolean
/** The session's running turn, or null when idle; goal time accrues only while one runs. */
runningTurn: { startedAt: number | null } | null
@@ -56,8 +60,8 @@ export function NativeChatThreadGoalBanner(props: {
const [expanded, setExpanded] = useState(false)
const { runningTurn } = props
const now = useNow(1_000, props.isVisible && goal.status === 'active' && runningTurn !== null)
const label = nativeChatThreadGoalStatusLabel(goal.status)
const statusChange = agentSessionThreadGoalStatusChange(goal.status)
const label = nativeChatThreadGoalStatusLabel(goal.status, runningTurn !== null)
const statusChange = props.hostChanges ? agentSessionThreadGoalStatusChange(goal.status) : null
if (label === null) {
return null
}
@@ -85,7 +89,8 @@ export function NativeChatThreadGoalBanner(props: {
<div className="flex shrink-0 items-center">
<GoalAction
label={translate('components.native-chat.goal.clear', 'Clear goal')}
disabled={pending}
// Claude's `/goal clear` must enter a fresh turn, like a composer send.
disabled={pending || (!props.hostChanges && props.sendUnavailable)}
onClick={() => onChange({ kind: 'clear' })}
>
<Trash2 className="size-3.5" />
@@ -1,13 +1,17 @@
import { translate } from '@/i18n/i18n'
import type { AgentJournalThreadGoalStatus } from '../../../../shared/agent-session-journal-types'
/** The banner's lead label, or null for a status the banner does not show. */
/** The banner's lead label, or null for a status the banner does not show. An active goal reads
* as pursued only while a turn runs; between turns (after a Stop) it is just set. */
export function nativeChatThreadGoalStatusLabel(
status: AgentJournalThreadGoalStatus
status: AgentJournalThreadGoalStatus,
working: boolean
): string | null {
switch (status) {
case 'active':
return translate('components.native-chat.goal.pursuing', 'Pursuing goal')
return working
? translate('components.native-chat.goal.pursuing', 'Pursuing goal')
: translate('components.native-chat.goal.set', 'Goal set')
case 'paused':
return translate('components.native-chat.goal.paused', 'Paused goal')
case 'blocked':
@@ -37,7 +37,7 @@ export function useNativeChatStructuredComposerTransport(args: {
)
return useMemo((): NativeChatStructuredComposerTransport => {
const threadGoal = controller.threadGoal
const setThreadGoalObjective = threadGoal
const setThreadGoalObjective = threadGoal?.hostChanges
? (objective: string) => threadGoal.change({ kind: 'set', objective })
: null
return {
@@ -79,6 +79,7 @@ export function useStructuredAgentSessionOptionState(args: {
sessionId: string
commands: readonly AgentSessionConversationCommand[]
threadGoal: AgentSessionOptionsResult['threadGoal']
reportedThreadGoal: AgentSessionOptionsResult['reportedThreadGoal']
contextUsage: AgentSessionOptionsResult['contextUsage']
acceptsImages: boolean | undefined
rewind: AgentSessionRewindSupport
@@ -159,6 +160,7 @@ export function useStructuredAgentSessionOptionState(args: {
sessionId,
commands: result.conversationCommands ?? [],
threadGoal: result.threadGoal,
reportedThreadGoal: result.reportedThreadGoal,
contextUsage: result.contextUsage,
acceptsImages: result.acceptsImages,
// A host that predates rewind does not name it.
@@ -298,6 +298,8 @@ export function useStructuredAgentSessionOptions(args: {
conversationCommands: support?.commands ?? [],
/** Absent unless this host and session can change the goal. */
threadGoal: support?.threadGoal,
/** A goal only the agent's own `/goal` changes; absent from a host that predates it. */
reportedThreadGoal: support?.reportedThreadGoal,
/** Absent from a host that predates it or a session that writes no context facts. */
contextUsage: support?.contextUsage,
/** Absent from a host that predates it, and until the options read answers. */
@@ -57,21 +57,40 @@ function mutateWith(answer: MutateCall): {
function harness(options: {
journalItems?: readonly AgentJournalRenderItem[]
support?: { current: AgentJournalThreadGoal | null }
reported?: { current: AgentJournalThreadGoal }
mutate?: StructuredAgentSessionMutate
send?: (text: string) => boolean | 'queued'
}) {
const mutate = options.mutate ?? mutateWith(async () => null).mutate
return renderHook(() =>
useStructuredAgentSessionThreadGoal({
journalItems: options.journalItems ?? [],
support: options.support,
mutate
reported: options.reported,
mutate,
send: options.send ?? (() => true)
})
)
}
describe('useStructuredAgentSessionThreadGoal', () => {
it('is absent until the host reports it can change this session goal', () => {
expect(harness({ journalItems: [goalRow(1, GOAL)] }).result.current).toBeNull()
it('does not show a persisted goal when the host advertises neither goal field', () => {
const fromWindow = harness({ journalItems: [goalRow(1, GOAL)] })
expect(fromWindow.result.current).toBeNull()
})
it('shows a goal only the agent changes, which clears by sending it /goal clear', async () => {
expect(harness({}).result.current).toBeNull()
const send = vi.fn(() => true)
const { mutate, calls } = mutateWith(async () => null)
const { result } = harness({ reported: { current: GOAL }, mutate, send })
expect(result.current).toMatchObject({ goal: GOAL, hostChanges: false, pending: false })
await act(async () => {
await expect(result.current!.change({ kind: 'clear' })).resolves.toBe(true)
})
expect(send).toHaveBeenCalledWith('/goal clear')
expect(calls).not.toHaveBeenCalled()
})
it('reads the goal off the loaded window, and off the host answer only when the window has none', () => {
@@ -4,43 +4,56 @@ import type {
AgentJournalThreadGoal
} from '../../../../shared/agent-session-journal-types'
import { currentAgentSessionThreadGoal } from '../../../../shared/agent-session-thread-goal'
import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire'
import type {
AgentSessionOptionsResult,
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../../shared/agent-session-wire'
} from '../../../../shared/agent-session-thread-goal-wire'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
export type StructuredAgentSessionThreadGoal = {
goal: AgentJournalThreadGoal | null
pending: boolean
/** False where only the agent's own `/goal` changes the goal: Orca sends it `/goal clear`, and
* the agent has no pause. */
hostChanges: boolean
/** Resolves false when the change was refused or not sent; the error surfaces separately. */
change: (change: AgentSessionThreadGoalChange) => Promise<boolean>
}
/** Null unless the host reported it can change this session's goal. */
const CLEAR_GOAL_COMMAND = '/goal clear'
/** Null unless the host advertises goal changes or goal reports for this session. */
export function useStructuredAgentSessionThreadGoal(args: {
journalItems: readonly AgentJournalRenderItem[]
support: AgentSessionOptionsResult['threadGoal']
reported: AgentSessionOptionsResult['reportedThreadGoal']
journalEpoch?: string | null
mutate: StructuredAgentSessionMutate
/** Sends the agent a command, for a goal only its own `/goal` changes. */
send: (text: string) => boolean | 'queued'
}): StructuredAgentSessionThreadGoal | null {
const { journalItems, mutate, support, journalEpoch } = args
const { journalItems, mutate, support, reported, journalEpoch, send } = args
const [pending, setPending] = useState(false)
const pendingRef = useRef(false)
// The loaded window reaches the live head, so a goal row in it is newer than the
// host's whole-journal answer; that answer covers only rows older than the window.
const report = support ?? reported
// The loaded window reaches the live head, so a goal row in it is newer than the host's
// whole-journal answer; that answer covers only rows older than the window.
const floor =
support?.contextFloor && (!journalEpoch || support.contextFloor.epoch === journalEpoch)
? support.contextFloor.sequence
report?.contextFloor && (!journalEpoch || report.contextFloor.epoch === journalEpoch)
? report.contextFloor.sequence
: 0
const loaded = useMemo(
() => currentAgentSessionThreadGoal(journalItems, floor),
[journalItems, floor]
)
const goal = loaded === undefined ? (support?.current ?? null) : loaded
const goal = loaded === undefined ? (report?.current ?? null) : loaded
const hostChanges = support !== undefined
const change = useCallback(
async (next: AgentSessionThreadGoalChange): Promise<boolean> => {
if (!hostChanges) {
return next.kind === 'clear' && send(CLEAR_GOAL_COMMAND) !== false
}
if (pendingRef.current) {
return false
}
@@ -58,7 +71,10 @@ export function useStructuredAgentSessionThreadGoal(args: {
setPending(false)
}
},
[mutate]
[hostChanges, mutate, send]
)
return support ? { goal, pending, change } : null
if (report === undefined || (!hostChanges && goal === null)) {
return null
}
return { goal, pending, hostChanges, change }
}
@@ -109,6 +109,7 @@ export function useStructuredAgentSession(args: {
sendSource,
unavailable,
threadGoal: threadGoalSupport,
reportedThreadGoal,
contextUsage: contextUsageSupport,
rewind: rewindSupport,
acceptsImages
@@ -135,13 +136,8 @@ export function useStructuredAgentSession(args: {
() => (transportState.queuedMessages ?? []).map((message) => message.messageId),
[transportState.queuedMessages]
)
const stopControl = useStructuredAgentSessionStop({
sessionId,
target,
transportState,
hostStopping,
mutate
})
const stopControlInput = { sessionId, target, transportState, hostStopping, mutate }
const stopControl = useStructuredAgentSessionStop(stopControlInput)
const prompts = pendingStructuredSessionPrompts(transportState)
const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts)
// A send after a command the queue will run goes behind it, even with follow-ups off.
@@ -167,12 +163,6 @@ export function useStructuredAgentSession(args: {
sendOptions
})
const threadGoal = useStructuredAgentSessionThreadGoal({
journalItems: transportState.journalItems,
journalEpoch: state.epoch,
support: threadGoalSupport,
mutate
})
const contextUsage = useStructuredAgentSessionContextUsage(
transportState.journalItems,
contextUsageSupport,
@@ -188,6 +178,7 @@ export function useStructuredAgentSession(args: {
const { pending } = sends
const commandWrite = useStructuredAgentSessionCommandWrite(sessionId, write)
const agentName = structuredAgentLabel(agent)
const sending = pending.some((entry) => entry.phase === 'sending')
// What the host refuses a rewind behind; a command's own hold is the runner's below.
const conversationBusy = transportState.conversationBusy || prompts.length > 0 || sending
@@ -205,17 +196,28 @@ export function useStructuredAgentSession(args: {
})
// A queued send is a card, never a transcript bubble.
const isWorking = transportState.isWorking || transportState.queueSendsNext
// A message typed during a command queues behind it on the host.
const send = (...input: Parameters<typeof sends.send>) =>
// Legacy: an older host refuses sends while a command runs; removable once those hosts age out.
(!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) &&
rewind.admitsSend() &&
sends.send(...input)
const threadGoal = useStructuredAgentSessionThreadGoal({
journalItems: transportState.journalItems,
journalEpoch: state.epoch,
support: threadGoalSupport,
reported: reportedThreadGoal,
mutate,
send
})
const transcriptPending = useMemo(
() => pendingSendsOutsideQueuedCards(pending, queuedMessageIds, isWorking),
[isWorking, pending, queuedMessageIds]
)
// What the transcript reads: the journal plus the one notice a cut turn with no row gets.
const transcriptItems = useMemo(
() =>
withNativeChatCutTurnNotices(transportState.journalItems, {
agentName: structuredAgentLabel(agent)
}),
[agent, transportState.journalItems]
() => withNativeChatCutTurnNotices(transportState.journalItems, { agentName }),
[agentName, transportState.journalItems]
)
const messages = useStructuredAgentSessionMessages(
transcriptItems,
@@ -246,7 +248,7 @@ export function useStructuredAgentSession(args: {
rewind,
conversationCommands,
...structuredConversationCommandRunner({
agentName: structuredAgentLabel(agent),
agentName,
pending: commandPending,
// A /compact waits in line only where its card renders.
commandsWait:
@@ -282,12 +284,7 @@ export function useStructuredAgentSession(args: {
sendOut: sending || sends.held,
/** The journal's rows for sent messages, which carry a rejected message's whole fact. */
submissions: transportState.submissions,
// A message typed during a command queues behind it on the host.
send: (...input: Parameters<typeof sends.send>) =>
// Legacy: an older host refuses sends while a command runs; removable once those hosts age out.
(!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) &&
rewind.admitsSend() &&
sends.send(...input),
send,
isWorking,
queueSendsNext: transportState.queueSendsNext,
workingStartedAt: transportState.turnTiming.workingStartedAt,
+1
View File
@@ -18796,6 +18796,7 @@
"pause": "Pause goal",
"resume": "Resume goal",
"pursuing": "Pursuing goal",
"set": "Goal set",
"paused": "Paused goal",
"blocked": "Goal blocked",
"limited": "Goal limited",
@@ -0,0 +1,13 @@
// What a client sends to change a structured session's thread goal (`agentSession.threadGoal`).
/** Longest objective a client may send; matches the provider's own limit. */
export const AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH = 4000
/** A client's change to the thread goal. `set` replaces the objective and makes
* it active, which the provider pursues without a separate turn. */
export type AgentSessionThreadGoalChange =
| { kind: 'set'; objective: string }
| { kind: 'status'; status: 'active' | 'paused' }
| { kind: 'clear' }
export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] }
+12 -6
View File
@@ -75,17 +75,23 @@ export function currentAgentSessionThreadGoal(
return undefined
}
/** The same answer for items a caller holds unordered, such as the host's own map. */
export function currentAgentSessionThreadGoalBySequence(
items: Iterable<GoalCandidate>
): AgentJournalThreadGoal | null | undefined {
let latest: GoalCandidate | null = null
/** The same answer and its source for items a caller holds unordered, such as the host's map. */
export function currentAgentSessionThreadGoalSourceBySequence<T extends GoalCandidate>(
items: Iterable<T>
): { item: T; goal: AgentJournalThreadGoal | null } | undefined {
let latest: T | null = null
for (const item of items) {
if (isGoalTransition(item) && (latest === null || item.sequence > latest.sequence)) {
latest = item
}
}
return latest === null ? undefined : goalFromRow(latest.body)
return latest === null ? undefined : { item: latest, goal: goalFromRow(latest.body) }
}
export function currentAgentSessionThreadGoalBySequence(
items: Iterable<GoalCandidate>
): AgentJournalThreadGoal | null | undefined {
return currentAgentSessionThreadGoalSourceBySequence(items)?.goal
}
/** Goal statuses that still describe work in progress, so readers keep them in view. */
+3 -12
View File
@@ -466,18 +466,6 @@ export type AgentSessionSlashCommand = {
* keeps rendering its curated catalog. */
export type AgentSessionCommandsResult = { commands?: AgentSessionSlashCommand[] }
/** Longest objective a client may send; matches the provider's own limit. */
export const AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH = 4000
/** A client's change to the thread goal. `set` replaces the objective and makes
* it active, which the provider pursues without a separate turn. */
export type AgentSessionThreadGoalChange =
| { kind: 'set'; objective: string }
| { kind: 'status'; status: 'active' | 'paused' }
| { kind: 'clear' }
export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] }
/** Provider-reported choices and effective next-turn values. Additive read-only
* surface so older hosts can reject it without changing structured v1 writes. */
export type AgentSessionOptionsResult = {
@@ -488,6 +476,9 @@ export type AgentSessionOptionsResult = {
* latest goal the whole journal records, for a client whose loaded page
* starts after it. */
threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor }
/** The goal a session's journal records where only the agent's own `/goal` command changes it.
* Separate from `threadGoal`, so a client that predates it never offers host controls. */
reportedThreadGoal?: { current: AgentJournalThreadGoal; contextFloor?: AgentJournalCursor }
/** Present only where this session writes context facts to its turn rows.
* `current` is the newest of each part the whole journal records, for a
* client whose loaded page starts after the row that carries it. */
@@ -32,9 +32,9 @@ import {
} from '../agent-session-question-answer'
import {
AGENT_SESSION_HISTORY_DIRECTIONS,
AGENT_SESSION_HISTORY_MAX_LIMIT,
AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH
AGENT_SESSION_HISTORY_MAX_LIMIT
} from '../agent-session-wire'
import { AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH } from '../agent-session-thread-goal-wire'
// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded.
export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH