Files
orca/src/main/codex/codex-goal-journal-rows.ts
T
Brennan Benson 563dd5487f feat(native-chat): show a Codex chat's goal above the composer, and set it from goal mode (#22377)
* feat(native-chat): show a Codex chat's goal above the composer and set it from goal mode

Structured Codex chat now treats the thread goal as session state: a banner above the
composer shows the current goal (pursuing / paused) with clear, pause/resume and expand;
/goal enters a goal mode whose send calls thread/goal/set; the objective is journaled as a
user message marked as sent as a goal. The banner is derived from the journaled goal rows,
which Codex's resume snapshot refreshes, so a reopened or adopted chat shows its goal.

Fixes STA-8159

* fix(native-chat): replace a recorded goal by clearing first, and recover a lost goal-change response

- A set while the journal records a goal (any status) clears it before setting,
  so the new goal starts with its own time and token counters instead of
  rewriting the old goal's objective in place.
- The threadGoal plan answers an unknown outcome from the goal the journal
  records and reruns otherwise, so one request timeout no longer refuses every
  later Clear/Pause/Resume as unknown for the mounted session.
- The goal-mode chip says "Exit goal mode"; "Clear goal" stays the banner's
  action on the provider goal.
- A typed bare /goal on Enter enters goal mode, the same as picking it.
- The renderer reads the goal off the tail of its ordered snapshot; the host's
  unordered map keeps the by-sequence reader.
- Drop the composer's duplicate in-flight guard; the goal controller already
  serializes changes.
- Pin that a counter-only revision reaches a subscriber's live page under its
  original sequence.

* fix(native-chat): keep a bare /goal inside goal mode as the entrance, and pin goal delivery and serialization

- A bare `/goal` submitted while already in goal mode re-enters the mode instead
  of setting a goal whose objective is the literal text "/goal".
- The counter-only revision pin now drives the host's own event sink bound to a
  real journal, so it goes red when the publish after a lifecycle transition is
  dropped; the previous fake sink never published.
- Pin that a set which threw after journaling its objective puts that objective
  back exactly once when the ledger reruns the same operation id.
- Cover the goal controller hook: absent without host support, the loaded window
  wins over the host's answer, a second change while one is unsettled answers
  false without a request, and a refused change frees the next one.

* fix(native-chat): resume a blocked or usage-limited goal, and keep goal-mode drafts honest

- The goal bar offers Resume on a blocked or usage-limited goal, which the
  provider resumes exactly as it resumes a paused one; a goal whose token budget
  is spent still offers only Clear. The rule lives beside the other goal facts
  in shared code so every reader answers it the same way.
- A `/goal <text>` typed inside goal mode sets the objective `<text>`, as it
  does outside goal mode, instead of a goal whose objective is the literal
  command.
- Setting a goal is a host round trip; a draft edited while it was in flight is
  no longer wiped when the goal lands, matching every other host command.
- Pin that a lost status-change response is read as applied only when the
  recorded goal is in that status, that a cleared row in the loaded window
  outranks the host's earlier answer, and that the PTY lane is untouched.

* fix(native-chat): keep the load-older anchor on the loaded window when a live revision lands below it

A live revision of a row keeps that row's original sequence. When the row is
older than the client's loaded window, the shared reducer merged it in and it
became the load-older anchor, so paging `before` it skipped every row between.
A goal's counter-only revisions during a long goal turn reach any client that
attached after the goal row left its window, so a reopened chat lost rows on
scroll-back.

The reducer now admits live rows only at or above the window's oldest row
while older rows remain on the host; the journal keeps the revision and the
page reader serves it once the window reaches the row. With nothing older on
the host the window is the whole journal, so a row below the head is admitted
as before.

Also drain accepted provider events before a goal set reads the journal to
decide whether it replaces a recorded goal.
2026-09-23 10:34:06 -07:00

134 lines
4.8 KiB
TypeScript

/**
* Codex thread goals reach us only as notifications: the `create_goal` tool call the
* model makes is never emitted as an item, so `thread/goal/updated` is the single
* truthful signal that a goal exists. The model narrates goals in prose either way,
* and that prose can be wrong — it claims "Goal created" in sessions where no goal
* was ever set — so the row below is what lets a reader tell the two apart.
*/
import type {
AgentJournalThreadGoal,
AgentJournalThreadGoalState
} from '../../shared/agent-session-journal-types'
import { isAgentJournalThreadGoalStatus } from '../../shared/agent-session-thread-goal'
const GOAL_UPDATED_METHOD = 'thread/goal/updated'
const GOAL_CLEARED_METHOD = 'thread/goal/cleared'
/** Status values Codex can report, mapped to how a reader would say them. */
const GOAL_STATUS_PREFIX: Record<string, string> = {
active: 'Goal set',
paused: 'Goal paused',
blocked: 'Goal blocked',
complete: 'Goal complete',
usageLimited: 'Goal stopped — usage limit',
budgetLimited: 'Goal stopped — token budget spent'
}
function goalRecord(payload: unknown): Record<string, unknown> | null {
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
return null
}
const goal = (payload as Record<string, unknown>).goal
return typeof goal === 'object' && goal !== null && !Array.isArray(goal)
? (goal as Record<string, unknown>)
: null
}
export function isCodexGoalFrameMethod(method: string): boolean {
return method === GOAL_UPDATED_METHOD || method === GOAL_CLEARED_METHOD
}
/** The sentence for a goal frame, or null when the frame is not one. */
export function codexGoalRowText(method: string, payload: unknown): string | null {
if (method === GOAL_CLEARED_METHOD) {
return 'Goal cleared'
}
if (method !== GOAL_UPDATED_METHOD) {
return null
}
const goal = goalRecord(payload)
const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : ''
const status = typeof goal?.status === 'string' ? goal.status : ''
// An unknown future status still says something true rather than falling back to
// the bare opcode.
const prefix = GOAL_STATUS_PREFIX[status] ?? 'Goal updated'
return objective ? `${prefix}: ${objective}` : prefix
}
/**
* What changes the visible sentence. Counters and budget stay in the raw disclosure but
* cannot append another row with identical copy.
*/
export function codexGoalRowSignature(method: string, payload: unknown): string | null {
if (method === GOAL_CLEARED_METHOD) {
return GOAL_CLEARED_METHOD
}
if (method !== GOAL_UPDATED_METHOD) {
return null
}
const goal = goalRecord(payload)
const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : ''
const status = typeof goal?.status === 'string' ? goal.status : ''
return `${GOAL_UPDATED_METHOD}\u0000${status}\u0000${objective}`
}
/** Provider-owned goal generation, stable while accounting counters change. */
export function codexGoalGeneration(payload: unknown): string | null {
const createdAt = goalRecord(payload)?.createdAt
return typeof createdAt === 'number' && Number.isFinite(createdAt) ? String(createdAt) : null
}
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** Codex's goal object in journal form; null when any field is missing or unknown. */
function codexThreadGoal(record: Record<string, unknown> | null): AgentJournalThreadGoal | null {
if (record === null) {
return null
}
const tokensUsed = finiteNumber(record.tokensUsed)
const timeUsedSeconds = finiteNumber(record.timeUsedSeconds)
const createdAt = finiteNumber(record.createdAt)
const updatedAt = finiteNumber(record.updatedAt)
const tokenBudget = record.tokenBudget === null ? null : finiteNumber(record.tokenBudget)
if (
typeof record.objective !== 'string' ||
typeof record.status !== 'string' ||
!isAgentJournalThreadGoalStatus(record.status) ||
tokensUsed === null ||
timeUsedSeconds === null ||
createdAt === null ||
updatedAt === null ||
(tokenBudget === null && record.tokenBudget !== null && record.tokenBudget !== undefined)
) {
return null
}
return {
objective: record.objective,
status: record.status,
tokenBudget,
tokensUsed,
timeUsedSeconds,
// Codex reports epoch seconds; the journal keeps epoch ms.
createdAt: createdAt * 1000,
updatedAt: updatedAt * 1000
}
}
/** The typed transition a goal frame records, or null for any other frame. */
export function codexThreadGoalState(
method: string,
payload: unknown
): AgentJournalThreadGoalState | null {
if (method === GOAL_CLEARED_METHOD) {
return { state: 'cleared' }
}
if (method !== GOAL_UPDATED_METHOD) {
return null
}
const goal = codexThreadGoal(goalRecord(payload))
return goal ? { state: 'set', goal } : null
}