diff --git a/src/main/claude/claude-background-task-roster-merge.ts b/src/main/claude/claude-background-task-roster-merge.ts new file mode 100644 index 00000000000..2b825a63835 --- /dev/null +++ b/src/main/claude/claude-background-task-roster-merge.ts @@ -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, + roster: ReadonlyMap, + limit: number +): Map { + const combined = new Map() + 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 +} diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index 43912bfa560..b23602b0fff 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -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', () => { diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 14f4271b7d8..77d3b298aea 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -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() private readonly retention = new ClaudeSettledBackgroundTasks() private readonly terminalTaskIds = new Set() 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, 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() + 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) } } diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 7dbade47848..1bc7b69d41a 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -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 diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 9732ca2ffb3..e6d5877c0b2 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -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 } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index a62b09a7ccf..1568c3a463f 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -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, 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) diff --git a/src/main/claude/claude-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts index 73064becec4..6df20fb69a8 100644 --- a/src/main/claude/claude-settled-background-tasks.ts +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -48,6 +48,27 @@ export function claudeBackgroundTaskDetail( } } +export function claudeBackgroundTaskDetails( + tasks: ReadonlyMap +): 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): boolean { + for (const task of tasks) { + if (task.backgrounded) { + return true + } + } + return false +} + function setBounded(map: Map, key: K, value: V): void { map.delete(key) map.set(key, value) diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 719e03d2447..88c5b2cd4bd 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -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() } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 779f1581460..6bad80f83f3 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -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, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 1cf0ded65df..12c062c56ac 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -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): void => { const init = readClaudeInit(message) diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 02dd74f31b0..5b9eb728a41 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -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[0] + failure: Parameters[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) diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index e75338f26e2..9021b466bb3 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -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() } diff --git a/src/main/claude/claude-thread-goal-journaling.test.ts b/src/main/claude/claude-thread-goal-journaling.test.ts new file mode 100644 index 00000000000..07fffc8e32b --- /dev/null +++ b/src/main/claude/claude-thread-goal-journaling.test.ts @@ -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 +// `` 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, 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, 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', '', `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', '', `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', '', `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', '', `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', '', `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', '', `Goal active: ${objective} (not yet evaluated)`) + result('status-result', { subtype: 'success', is_error: false, num_turns: 0 }) + reply('clear', '', `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' } + ]) + }) +}) diff --git a/src/main/claude/claude-thread-goal.ts b/src/main/claude/claude-thread-goal.ts new file mode 100644 index 00000000000..d049b66d834 --- /dev/null +++ b/src/main/claude/claude-thread-goal.ts @@ -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 `` 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 +type GoalReport = { kind: 'set'; objective: string } | { kind: 'cleared' } + +function syntheticReplyText(message: Record): string | null { + const reply = claudeRecord(message.message) + if ( + message.type !== 'assistant' || + !isRootClaudeFrame(message) || + reply?.model !== '' + ) { + 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, + 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): void { + const reply = claudeRecord(message.message) + if ( + message.type === 'assistant' && + isRootClaudeFrame(message) && + reply?.model !== '' && + 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, + 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 + } +} diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index 59da154e09f..c521221390e 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -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' diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 10fd64a7f65..bbe6fa47530 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -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 => diff --git a/src/main/native-chat/agent-session-journal/journal-thread-goal-source.ts b/src/main/native-chat/agent-session-journal/journal-thread-goal-source.ts new file mode 100644 index 00000000000..c494ea79afd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-thread-goal-source.ts @@ -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 +): 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 + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 664cb423838..8a3b493b766 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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 { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 880a7dfa86b..c16548ae2af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 87fd5567cb5..e84a2941829 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -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 { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 6ffbb4b5ba9..679240d8ebc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -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 = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 853786e0933..092bd2e7fe1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -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 { + if (hostChangesGoal) { + return { threadGoal: { current, ...contextFloor } } + } + return current ? { reportedThreadGoal: { current, ...contextFloor } } : {} +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 7bb119ea22b..351e49fab1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 1310663c998..62bb50c17d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx index 80989c5defb..b2737b8a927 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx @@ -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 ( ) @@ -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()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 88389dca2bc..3a759d09222 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -191,6 +191,7 @@ afterEach(() => { updatedAt: 1 }, pending: false, + hostChanges: true, change: vi.fn() } mocks.queuedCards = [ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index f886ea31e2d..c08deab4ca3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -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 { - 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(), @@ -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 diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index c5914fe871f..a706a61319b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -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. */} diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx index 3eeee7b2652..7881d74c5f6 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx @@ -38,6 +38,8 @@ function renderBanner( { 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() }) diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx index d9cd3ce33ec..60617c86855 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx @@ -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: {
onChange({ kind: 'clear' })} > diff --git a/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts b/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts index 9e52e452329..5ff13e27dfa 100644 --- a/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts +++ b/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts @@ -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': diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 189fabfaa12..6b68bfda9a9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -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 { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 7317d342621..3111fcbae6f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -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. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index b6cfd316a15..11a1ab3b4a9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -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. */ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx index 2de4a2564e4..973b3b3f693 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts index d3dc5bd0009..f66c34ef566 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -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 } -/** 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 => { + 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 } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 746412bfb37..9a5f5465efe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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) => + // 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) => - // 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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f1c514f8754..44483b889ca 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/shared/agent-session-thread-goal-wire.ts b/src/shared/agent-session-thread-goal-wire.ts new file mode 100644 index 00000000000..ca25450ed04 --- /dev/null +++ b/src/shared/agent-session-thread-goal-wire.ts @@ -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'] } diff --git a/src/shared/agent-session-thread-goal.ts b/src/shared/agent-session-thread-goal.ts index 5f15bb3b5fd..d714fbaabe3 100644 --- a/src/shared/agent-session-thread-goal.ts +++ b/src/shared/agent-session-thread-goal.ts @@ -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 -): 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( + items: Iterable +): { 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 +): AgentJournalThreadGoal | null | undefined { + return currentAgentSessionThreadGoalSourceBySequence(items)?.goal } /** Goal statuses that still describe work in progress, so readers keep them in view. */ diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 37619a2cdc8..7545dc1d725 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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. */ diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 21a6c682120..1c46bf6cc9a 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -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