mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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 } } : {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+38
-4
@@ -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())
|
||||
|
||||
+1
@@ -191,6 +191,7 @@ afterEach(() => {
|
||||
updatedAt: 1
|
||||
},
|
||||
pending: false,
|
||||
hostChanges: true,
|
||||
change: vi.fn()
|
||||
}
|
||||
mocks.queuedCards = [
|
||||
|
||||
+3
-7
@@ -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':
|
||||
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
+22
-3
@@ -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', () => {
|
||||
|
||||
+27
-11
@@ -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,
|
||||
|
||||
@@ -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'] }
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user