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.
This commit is contained in:
Brennan Benson
2026-09-23 10:34:06 -07:00
committed by GitHub
parent a375936c04
commit 563dd5487f
61 changed files with 2843 additions and 99 deletions
+59
View File
@@ -6,6 +6,12 @@
* 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'
@@ -72,3 +78,56 @@ 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
}
@@ -43,6 +43,7 @@ function goalJournal(
const rows = new Map<string, AgentJournalRenderItem>()
const writes: string[] = []
const deferred = createDeferredStructuredAgentSessionEventSink(options)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake journal exposing only the members the goal translator and deferred sink call.
const journal = {
get epoch() {
return `epoch-${epochNumber}`
@@ -68,11 +69,11 @@ function goalJournal(
items: [...rows.values()].sort((left, right) => left.sequence - right.sequence),
submissions: []
}),
visitItems: (visit: (itemId: string, sequence: number) => void) => {
visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => {
visits += 1
for (const item of rows.values()) {
visitedItems += 1
visit(item.itemId, item.sequence)
visit(item.itemId, item.sequence, item.body)
}
}
} as unknown as StructuredAgentSessionEventTarget['journal']
@@ -354,12 +355,22 @@ describe('codex goal lifecycle resume', () => {
prefix === 'Goal cleared' ? prefix : `${prefix}: Keep the current scratch directory tidy.`
)
)
expect(journal.writes).toHaveLength(writesBeforeResume)
expect(journal.publishes()).toBe(publishesBeforeResume)
// A resumed goal's fresh accounting revises the row it already has: no new
// row, and the text a reader sees is unchanged. A cleared goal has no accounting.
const cleared = scenario.resumed.method === 'thread/goal/cleared'
expect(journal.writes).toHaveLength(writesBeforeResume + (cleared ? 0 : 1))
expect(journal.publishes()).toBe(publishesBeforeResume + (cleared ? 0 : 1))
expect(journal.writes.at(-1)).toBe(acceptedOccurrence)
expect(journal.rows().find((row) => row.itemId === acceptedOccurrence)?.body).toEqual(
acceptedBody
)
const revised = journal.rows().find((row) => row.itemId === acceptedOccurrence)
if (cleared) {
expect(revised?.body).toEqual(acceptedBody)
} else {
expect(revised?.revision).toBe(2)
expect(revised?.body).toMatchObject({
text: acceptedBody?.kind === 'status' ? acceptedBody.text : undefined,
threadGoal: { goal: { tokensUsed: 12_345, timeUsedSeconds: 42 } }
})
}
resumed.dispose()
})
})
@@ -0,0 +1,188 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type {
AgentJournalCursor,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { currentAgentSessionThreadGoal } from '../../shared/agent-session-thread-goal'
import type { AgentSessionHistoryPage } from '../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexJournalGoals } from './codex-structured-journal-goals'
const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD }
}
let root: string | null = null
const journals = createTrackedJournalOpener()
afterEach(async () => {
await journals.closeAll()
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
function goalFrame(goal: Record<string, unknown> = {}) {
return {
threadId: THREAD,
turnId: null,
goal: {
threadId: THREAD,
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1789067988,
updatedAt: 1789067988,
...goal
}
}
}
/** The host's own sink bound to a real journal. Its publish is what a subscriber
* receives: the page after the cursor it had caught up to. */
function journalSink(journal: AgentSessionJournal) {
const deferred = createDeferredStructuredAgentSessionEventSink()
const published: AgentSessionHistoryPage[] = []
let subscriberCursor: AgentJournalCursor | null = null
deferred.bind({
journal,
fence: 1,
publish: () => {
if (subscriberCursor === null) {
return
}
const result = readAgentSessionHistory(journal, {
sessionId: IDENTITY.sessionId,
direction: 'after',
cursor: subscriberCursor
})
if (result.ok) {
published.push(result.page)
}
}
})
return {
sink: deferred.sink,
drained: () => deferred.drained(),
/** A subscriber caught up to the journal's head from here on. */
subscribe: () => {
subscriberCursor = journal.cursor()
},
published
}
}
describe('codex goal accounting revisions', () => {
it('revises the goal row in place: one visible row, pinned sequence, fresh counters', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-goal-revision-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'earlier' },
{ kind: 'status', text: 'Context compacted' },
{ fence: 1 }
)
const { sink, drained, subscribe, published } = journalSink(journal)
const goals = new CodexJournalGoals(sink)
goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() })
await drained()
const [, created] = journal.snapshot().items
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'later' },
{ kind: 'status', text: 'Something after the goal' },
{ fence: 1 }
)
subscribe()
// A tick inside the revision interval is not worth a persisted row, or a publish.
goals.handle({
threadId: THREAD,
method: 'thread/goal/updated',
params: goalFrame({ timeUsedSeconds: 10, updatedAt: 1789067998 })
})
await drained()
expect(journal.snapshot().items[1]?.revision).toBe(created?.revision)
expect(published).toEqual([])
goals.handle({
threadId: THREAD,
method: 'thread/goal/updated',
params: goalFrame({ tokensUsed: 900, timeUsedSeconds: 45, updatedAt: 1789068033 })
})
await drained()
const items = journal.snapshot().items
expect(items.map((item) => item.itemId)).toEqual([
'orca:earlier',
created?.itemId,
'orca:later'
])
const revised = items[1]
expect(revised?.sequence).toBe(created?.sequence)
expect(revised?.revision).toBe((created?.revision ?? 0) + 1)
expect(currentAgentSessionThreadGoal(items)).toMatchObject({
tokensUsed: 900,
timeUsedSeconds: 45,
updatedAt: 1789068033_000
})
// The write reaches a caught-up subscriber as one live page carrying the
// revised row under its original sequence, not a second goal row.
expect(published).toEqual([
expect.objectContaining({
items: [
expect.objectContaining({
itemId: created?.itemId,
sequence: created?.sequence,
revision: (created?.revision ?? 0) + 1
})
],
removedItemIds: []
})
])
goals.dispose()
})
it('refreshes the row from a resume snapshot of the same goal', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
const first = journalSink(journal)
const prior = new CodexJournalGoals(first.sink)
prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() })
await first.drained()
prior.dispose()
const [created] = journal.snapshot().items
const second = journalSink(journal)
const resumed = new CodexJournalGoals(second.sink)
// Only a few seconds more: a resume snapshot still refreshes, since no live tick follows.
const snapshot = goalFrame({ timeUsedSeconds: 3, updatedAt: 1789067991 })
resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot })
await second.drained()
resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot })
await second.drained()
const items = journal.snapshot().items
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
itemId: created?.itemId,
sequence: created?.sequence,
revision: (created?.revision ?? 0) + 1
})
expect(currentAgentSessionThreadGoal(items)?.timeUsedSeconds).toBe(3)
resumed.dispose()
})
})
@@ -64,6 +64,34 @@ describe('codex goal frames as journal rows', () => {
expect(texts(rows)).toEqual(['Goal set: Keep the current scratch directory tidy.'])
})
it('records the goal in typed form so readers never parse the frame head', () => {
const { rows, frames: generic } = frames()
generic.appendUnhandled(
'notification:thread/goal/updated',
goalFrame({ tokenBudget: 50_000, tokensUsed: 12, timeUsedSeconds: 9 }),
THREAD
)
generic.appendUnhandled('notification:thread/goal/cleared', { threadId: THREAD }, THREAD)
expect(rows.map((row) => (row.kind === 'status' ? row.threadGoal : undefined))).toEqual([
{
state: 'set',
goal: {
objective: 'Keep the current scratch directory tidy.',
status: 'active',
tokenBudget: 50_000,
tokensUsed: 12,
timeUsedSeconds: 9,
// Codex reports epoch seconds; the journal keeps epoch ms.
createdAt: 1789067988_000,
updatedAt: 1789067988_000
}
},
{ state: 'cleared' }
])
})
it('does not repeat the row while only the counters climb', () => {
const { rows, frames: generic } = frames()
@@ -1,4 +1,7 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import type {
StructuredAgentSessionEventSink,
@@ -22,9 +25,38 @@ import {
import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits'
import { appendCodexLifecycleTransition } from './codex-structured-journal-sink'
type GoalAccounting = { key: string; timeUsedSeconds: number; tokenBudget: number | null }
type GoalThreadState = {
signature: string
occurrence: string
/** Accounting last journaled for this goal; null when the row carries none. */
accounting: GoalAccounting | null
}
// Codex re-sends accounting every few seconds of a running turn, and each revision
// is a persisted row; readers extrapolate between revisions while a turn runs.
const GOAL_ACCOUNTING_REVISION_SECONDS = 30
function goalAccounting(body: AgentJournalItemBody | undefined): GoalAccounting | null {
if (body?.kind !== 'status' || body.threadGoal?.state !== 'set') {
return null
}
const { tokenBudget, tokensUsed, timeUsedSeconds, updatedAt } = body.threadGoal.goal
return {
key: JSON.stringify([tokenBudget, tokensUsed, timeUsedSeconds, updatedAt]),
timeUsedSeconds,
tokenBudget
}
}
/** Whether a live accounting tick is worth a revision of the goal's row. */
function accountingOutgrew(previous: GoalAccounting | null, next: GoalAccounting): boolean {
return (
previous === null ||
previous.tokenBudget !== next.tokenBudget ||
Math.abs(next.timeUsedSeconds - previous.timeUsedSeconds) >= GOAL_ACCOUNTING_REVISION_SECONDS
)
}
/** Persists provider-owned goal lifecycle notifications outside generic-row policy. */
@@ -55,15 +87,6 @@ export class CodexJournalGoals {
const providerGeneration =
reportedGeneration === null ? null : codexGoalJournalDigest(`provider:${reportedGeneration}`)
const signatureKey = codexGoalJournalDigest(`${signature}\u0000${providerGeneration ?? ''}`)
const previous = this.stateByThread.get(thread)
if (previous?.signature === signatureKey) {
this.remember(thread, previous)
return CODEX_JOURNAL_ADMITTED
}
const occurrence = previous
? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signatureKey]))
: codexGoalJournalDigest(JSON.stringify([thread, signatureKey]))
const state = { signature: signatureKey, occurrence }
const translated = unhandledProviderFrameJournalItem(
'codex',
`notification:${event.method}`,
@@ -72,6 +95,26 @@ export class CodexJournalGoals {
if (!translated) {
return { accepted: false, reason: 'untranslated' }
}
const accounting = goalAccounting(translated.body)
const previous = this.stateByThread.get(thread)
const sameGoal = previous?.signature === signatureKey
if (
previous &&
sameGoal &&
(accounting === null || !accountingOutgrew(previous.accounting, accounting))
) {
this.remember(thread, previous)
return CODEX_JOURNAL_ADMITTED
}
// Counter-only changes revise the goal's existing row: same identity, so the
// journal bumps its revision and keeps its sequence.
const occurrence =
previous && sameGoal
? previous.occurrence
: previous
? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signatureKey]))
: codexGoalJournalDigest(JSON.stringify([thread, signatureKey]))
const state = { signature: signatureKey, occurrence, accounting }
const admission = appendCodexLifecycleTransition(
this.sink,
codexGoalJournalIdentity(thread, signatureKey, occurrence),
@@ -81,6 +124,7 @@ export class CodexJournalGoals {
journal,
thread,
signatureKey,
accounting,
event.method === 'thread/goal/cleared'
)
)
@@ -130,12 +174,18 @@ export class CodexJournalGoals {
journal: StructuredAgentSessionLifecycleJournal,
thread: string,
signature: string,
accounting: GoalAccounting | null,
requirePrevious: boolean
): AgentJournalItemIdentity | null {
this.seedDurableState(journal)
const previous = this.durableStateByThread.get(thread) ?? null
if (previous?.signature === signature) {
return null
// Same goal with fresh accounting, e.g. a resume snapshot: revise its row in place.
if (accounting === null || previous.accounting?.key === accounting.key) {
return null
}
previous.accounting = accounting
return codexGoalJournalIdentity(thread, signature, previous.occurrence)
}
// Codex sends a cleared snapshot while resuming threads that never had a goal.
if (previous === null && requirePrevious) {
@@ -144,7 +194,7 @@ export class CodexJournalGoals {
const occurrence = previous
? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signature]))
: codexGoalJournalDigest(JSON.stringify([thread, signature]))
this.durableStateByThread.set(thread, { signature, occurrence })
this.durableStateByThread.set(thread, { signature, occurrence, accounting })
return codexGoalJournalIdentity(thread, signature, occurrence)
}
@@ -152,19 +202,23 @@ export class CodexJournalGoals {
if (this.durableJournal === journal && this.durableEpoch === journal.epoch) {
return
}
const latest = new Map<string, { state: CodexGoalJournalState; sequence: number }>()
journal.visitItems((itemId, sequence) => {
const latest = new Map<
string,
{ state: CodexGoalJournalState; sequence: number; accounting: GoalAccounting | null }
>()
journal.visitItems((itemId, sequence, body) => {
const state = parseCodexGoalJournalItemId(itemId)
const previous = state ? latest.get(state.thread) : undefined
if (state && (!previous || sequence > previous.sequence)) {
latest.set(state.thread, { state, sequence })
latest.set(state.thread, { state, sequence, accounting: goalAccounting(body) })
}
})
this.durableStateByThread.clear()
for (const [thread, { state }] of latest) {
for (const [thread, { state, accounting }] of latest) {
this.durableStateByThread.set(thread, {
signature: state.signature,
occurrence: state.occurrence
occurrence: state.occurrence,
accounting
})
}
this.durableJournal = journal
@@ -36,6 +36,7 @@ import {
import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import { acquireCodexStructuredSession } from './codex-structured-session-acquire'
import { changeCodexThreadGoal } from './codex-structured-thread-goal'
import {
answerCodexStructuredPrompt,
cancelCodexStructuredTurn
@@ -268,6 +269,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
)
}
changeThreadGoal: NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']> = (input) =>
changeCodexThreadGoal(
this.session(input.sessionId),
input.change,
input.replacesGoal,
this.deps.requestTimeoutMs
)
supportsThreadGoal = (sessionId: string): boolean => this.sessions.has(sessionId)
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) =>
answerCodexStructuredPrompt({ request, sessions: this.sessions })
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { CodexAppServerRequestError } from './codex-app-server-request-error'
import { changeCodexThreadGoal, codexThreadGoalRequests } from './codex-structured-thread-goal'
import type { CodexSession } from './codex-structured-session-state'
const THREAD = 'thread-1'
function session(request: (...args: unknown[]) => Promise<unknown>) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only these two fields.
return { threadId: THREAD, connection: { request } } as unknown as Pick<
CodexSession,
'connection' | 'threadId'
>
}
describe('codex thread goal requests', () => {
it('sets an objective as an active goal with no turn of its own', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'set', objective: 'Ship it' }, false)).toEqual([
{
method: 'thread/goal/set',
params: { threadId: THREAD, objective: 'Ship it', status: 'active' }
}
])
})
it('clears the existing goal before setting a new objective, so the new goal starts fresh', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'set', objective: 'Ship it' }, true)).toEqual([
{ method: 'thread/goal/clear', params: { threadId: THREAD } },
{
method: 'thread/goal/set',
params: { threadId: THREAD, objective: 'Ship it', status: 'active' }
}
])
})
it('pauses and resumes by status alone, keeping the objective', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'status', status: 'paused' }, true)).toEqual([
{ method: 'thread/goal/set', params: { threadId: THREAD, status: 'paused' } }
])
expect(codexThreadGoalRequests(THREAD, { kind: 'status', status: 'active' }, true)).toEqual([
{ method: 'thread/goal/set', params: { threadId: THREAD, status: 'active' } }
])
})
it('clears with only the thread id', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'clear' }, true)).toEqual([
{ method: 'thread/goal/clear', params: { threadId: THREAD } }
])
})
it('sends each request in order with the session deadline', async () => {
const request = vi.fn(async () => ({ goal: null }))
await expect(
changeCodexThreadGoal(session(request), { kind: 'set', objective: 'Ship it' }, true, 5_000)
).resolves.toEqual({ ok: true })
expect(request.mock.calls).toEqual([
['thread/goal/clear', { threadId: THREAD }, { timeoutMs: 5_000 }],
[
'thread/goal/set',
{ threadId: THREAD, objective: 'Ship it', status: 'active' },
{ timeoutMs: 5_000 }
]
])
})
it('stops at a refused clear, so a refused replacement leaves the old goal alone', async () => {
const request = vi.fn(async (method: unknown) => {
if (method === 'thread/goal/clear') {
throw new CodexAppServerRequestError('thread/goal/clear', -32600, 'goals are disabled')
}
return { goal: null }
})
await expect(
changeCodexThreadGoal(session(request), { kind: 'set', objective: 'Ship it' }, true, 5_000)
).resolves.toEqual({ ok: false, rejected: 'goals are disabled' })
expect(request).toHaveBeenCalledTimes(1)
})
it('reports a provider refusal and rethrows anything that leaves the effect unknown', async () => {
const refused = session(async () => {
throw new CodexAppServerRequestError('thread/goal/set', -32600, 'goals feature is disabled')
})
await expect(
changeCodexThreadGoal(refused, { kind: 'set', objective: 'Ship it' }, false, undefined)
).resolves.toEqual({ ok: false, rejected: 'goals feature is disabled' })
const lost = session(async () => {
throw new Error('codex app-server request timed out')
})
await expect(changeCodexThreadGoal(lost, { kind: 'clear' }, false, undefined)).rejects.toThrow(
'timed out'
)
})
})
@@ -0,0 +1,52 @@
import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-wire'
import { isCodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
type CodexThreadGoalRequest = {
method: 'thread/goal/set' | 'thread/goal/clear'
params: Record<string, unknown>
}
/** The app-server requests that make one goal change on a thread, in order. */
export function codexThreadGoalRequests(
threadId: string,
change: AgentSessionThreadGoalChange,
replacesGoal: boolean
): CodexThreadGoalRequest[] {
const clear: CodexThreadGoalRequest = { method: 'thread/goal/clear', params: { threadId } }
if (change.kind === 'clear') {
return [clear]
}
if (change.kind === 'status') {
return [{ method: 'thread/goal/set', params: { threadId, status: change.status } }]
}
// `set` on an existing goal rewrites its objective and keeps its id and usage
// counters, so a replacement clears first. An active goal on an idle thread
// starts work by itself, so a set needs no turn.
return [
...(replacesGoal ? [clear] : []),
{
method: 'thread/goal/set',
params: { threadId, objective: change.objective, status: 'active' }
}
]
}
export async function changeCodexThreadGoal(
session: Pick<CodexSession, 'connection' | 'threadId'>,
change: AgentSessionThreadGoalChange,
replacesGoal: boolean,
timeoutMs: number | undefined
): Promise<{ ok: true } | { ok: false; rejected: string }> {
try {
for (const request of codexThreadGoalRequests(session.threadId, change, replacesGoal)) {
await session.connection.request(request.method, request.params, { timeoutMs })
}
return { ok: true }
} catch (error) {
if (isCodexAppServerRequestError(error)) {
return { ok: false, rejected: error.message }
}
throw error
}
}
@@ -8,10 +8,12 @@ import type {
AgentJournalItemIdentity,
AgentJournalSnapshot,
AgentJournalSubmission,
AgentJournalThreadGoal,
AgentJournalTurnLifecycle,
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 {
activeStructuredAgentSessionTurnIdBySequence,
newestStructuredAgentSessionTurnBySequence
@@ -185,6 +187,10 @@ export class AgentSessionJournal {
newestTurn = (): AgentJournalTurnLifecycle | null =>
newestStructuredAgentSessionTurnBySequence(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
/** Includes revisions and completion tombstones, whose timestamps disappear from render items. */
lastActivityAt = (): number => this.state.lastActivityAt
@@ -86,6 +86,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) =>
this.owner(input.sessionId).cancelTurn(input)
changeThreadGoal: NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']> = (input) => {
const change = this.owner(input.sessionId).changeThreadGoal
if (!change) {
return Promise.resolve({ ok: false, rejected: 'Goals are unavailable for this provider.' })
}
return change(input)
}
supportsThreadGoal = (sessionId: string): boolean =>
this.liveOwnerOrNull(sessionId)?.supportsThreadGoal?.(sessionId) ?? false
stopBackgroundTasks: NonNullable<StructuredAgentSessionAdapter['stopBackgroundTasks']> = (
input
) => {
@@ -27,6 +27,7 @@ import type {
AgentSessionBackgroundTaskState,
AgentSessionOptionsResult,
AgentSessionSlashCommand,
AgentSessionThreadGoalChange,
AgentSessionWireRefusalCode
} from '../../../shared/agent-session-wire'
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
@@ -217,6 +218,18 @@ export type StructuredAgentSessionAdapter = {
* delivery fence may have waited. Absent for direct callers with no journal. */
resolveLiveTurnId?: () => string | null
}): Promise<{ cancelled: boolean }>
/** Changes the provider thread's goal. `rejected` is the provider refusing the
* change; a throw leaves its effect unknown. Absent where no goal exists. */
changeThreadGoal?(input: {
sessionId: string
fence: number
change: AgentSessionThreadGoalChange
/** True when the journal records a goal, whatever its status: a `set` must
* start a new goal rather than rewrite that one's objective in place. */
replacesGoal: boolean
}): Promise<{ ok: true } | { ok: false; rejected: string }>
/** Whether this live session can change its goal. */
supportsThreadGoal?(sessionId: string): boolean
stopBackgroundTasks?(input: {
sessionId: string
fence: number
@@ -17,8 +17,11 @@ import type {
AgentSessionOptionResult,
AgentSessionOptionsResult,
AgentSessionPromptResult,
AgentSessionSendResult
AgentSessionSendResult,
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../shared/agent-session-wire'
import { threadGoalPlan } from './structured-agent-session-thread-goal'
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
import {
cancelPlan,
@@ -151,6 +154,14 @@ export function setStructuredAgentSessionOption(
return mutate(context, caller, params.envelope, setOptionPlan(params))
}
export function changeStructuredAgentSessionThreadGoal(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange }
): Promise<AgentSessionMutationResult<AgentSessionThreadGoalResult>> {
return mutate(context, caller, params.envelope, threadGoalPlan(params))
}
export function readStructuredAgentSessionOptions(
context: StructuredAgentSessionMutationContext,
sessionId: string
@@ -171,7 +182,10 @@ export function readStructuredAgentSessionOptions(
supported: false,
reason: 'unsupported'
}),
conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear']
conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear'],
...(context.deps.adapter.supportsThreadGoal?.(sessionId)
? { threadGoal: { current: session.journal.threadGoal() } }
: {})
}
})
}
@@ -264,6 +278,10 @@ export function structuredAgentSessionMutationDelegates(
caller: StructuredAgentSessionCaller,
params: Parameters<typeof setStructuredAgentSessionOption>[2]
) => setStructuredAgentSessionOption(context(), caller, params),
changeThreadGoal: (
caller: StructuredAgentSessionCaller,
params: Parameters<typeof changeStructuredAgentSessionThreadGoal>[2]
) => changeStructuredAgentSessionThreadGoal(context(), caller, params),
readOptions: (sessionId: string) => readStructuredAgentSessionOptions(context(), sessionId)
}
}
@@ -290,6 +290,7 @@ export class StructuredAgentSessionHost {
cancel = this.mutations.cancel
respondToPrompt = this.mutations.respondToPrompt
setOption = this.mutations.setOption
changeThreadGoal = this.mutations.changeThreadGoal
readOptions = this.mutations.readOptions
requestHandoff = (
@@ -0,0 +1,330 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalThreadGoal,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
journalRecordsThreadGoalChange,
performThreadGoalChange,
threadGoalPlan
} from './structured-agent-session-thread-goal'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
let root: string | null = null
const journals = createTrackedJournalOpener()
afterEach(async () => {
await journals.closeAll()
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
async function openJournal(): Promise<AgentSessionJournal> {
root ??= await mkdtemp(join(tmpdir(), 'orca-thread-goal-'))
return journals.open({ identity: IDENTITY, journalDir: root })
}
const GOAL: AgentJournalThreadGoal = {
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 1,
timeUsedSeconds: 2,
createdAt: 3_000,
updatedAt: 4_000
}
function appendGoalRow(
journal: AgentSessionJournal,
overrides: Partial<AgentJournalThreadGoal>
): Promise<unknown> {
return journal.appendItem(
{ provider: 'orca', clientMessageId: `goal-row:${journal.snapshot().items.length}` },
{ kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: { ...GOAL, ...overrides } } },
{ fence: 1 }
)
}
function context(
journal: AgentSessionJournal,
adapter: Partial<StructuredAgentSessionAdapter>,
flushStreamedEvents: () => Promise<void> = async () => undefined
): AgentSessionTurnContext {
return {
sessionId: 'session-1',
journal,
fence: 1,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods.
adapter: adapter as StructuredAgentSessionAdapter,
persistOptions: async () => undefined,
resolvedBy: 'client-1',
publish: vi.fn(),
flushStreamedEvents,
now: () => 1
}
}
describe('performThreadGoalChange', () => {
it('journals the objective as a user message sent as a goal, durably', async () => {
const journal = await openJournal()
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
const result = await performThreadGoalChange(
context(journal, { changeThreadGoal, supportsThreadGoal: () => true }),
{ clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } }
)
expect(result).toEqual({ ok: true, value: { change: 'set' } })
expect(changeThreadGoal).toHaveBeenCalledWith({
sessionId: 'session-1',
fence: 1,
change: { kind: 'set', objective: 'Ship the parser' },
replacesGoal: false
})
const expected = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Ship the parser' }],
sentAs: 'goal'
}
expect(journal.snapshot().items.map((item) => item.body)).toEqual([expected])
// The marker must survive a reopen: the persisted row validator admits it.
await journal.close()
const reopened = await openJournal()
expect(reopened.snapshot().items.map((item) => item.body)).toEqual([expected])
})
it('removes the objective when the provider refuses the goal', async () => {
const journal = await openJournal()
const ctx = context(journal, {
changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }),
supportsThreadGoal: () => true
})
const result = await performThreadGoalChange(ctx, {
clientOperationId: 'op-2',
change: { kind: 'set', objective: 'Ship the parser' }
})
expect(result).toEqual({
ok: false,
refusal: { code: 'agent_session_operation_invalid', message: 'goals feature is disabled' }
})
expect(journal.snapshot().items).toEqual([])
})
it('removes the objective when the provider request fails outright', async () => {
const journal = await openJournal()
const ctx = context(journal, {
changeThreadGoal: async () => {
throw new Error('connection closed')
},
supportsThreadGoal: () => true
})
await expect(
performThreadGoalChange(ctx, {
clientOperationId: 'op-3',
change: { kind: 'set', objective: 'Ship the parser' }
})
).rejects.toThrow('connection closed')
expect(journal.snapshot().items).toEqual([])
})
it('puts the objective back, once, when a withdrawn set runs again', async () => {
const journal = await openJournal()
const attempts: (() => Promise<{ ok: true }>)[] = [
async () => {
throw new Error('request timed out')
},
async () => ({ ok: true as const })
]
const ctx = context(journal, {
changeThreadGoal: () => attempts.shift()!(),
supportsThreadGoal: () => true
})
const input = {
clientOperationId: 'op-9',
change: { kind: 'set' as const, objective: 'Ship the parser' }
}
await expect(performThreadGoalChange(ctx, input)).rejects.toThrow('request timed out')
expect(journal.snapshot().items).toEqual([])
// The ledger reruns the same operation id; its tombstoned row revives, not doubles.
await expect(performThreadGoalChange(ctx, input)).resolves.toEqual({
ok: true,
value: { change: 'set' }
})
expect(journal.snapshot().items.map((item) => item.body)).toEqual([
{
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Ship the parser' }],
sentAs: 'goal'
}
])
})
it('journals nothing for a status change or clear', async () => {
const journal = await openJournal()
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true })
await performThreadGoalChange(ctx, {
clientOperationId: 'op-3',
change: { kind: 'status', status: 'paused' }
})
await performThreadGoalChange(ctx, { clientOperationId: 'op-4', change: { kind: 'clear' } })
expect(changeThreadGoal).toHaveBeenCalledTimes(2)
expect(journal.snapshot().items).toEqual([])
})
it('refuses a session whose provider has no goals, without touching the journal', async () => {
const journal = await openJournal()
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
const result = await performThreadGoalChange(
context(journal, { changeThreadGoal, supportsThreadGoal: () => false }),
{ clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } }
)
expect(result).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_invalid' }
})
expect(changeThreadGoal).not.toHaveBeenCalled()
expect(journal.snapshot().items).toEqual([])
})
it('reports the latest goal the whole journal records', async () => {
const journal = await openJournal()
expect(journal.threadGoal()).toBeNull()
await appendGoalRow(journal, { status: 'paused' })
expect(journal.threadGoal()).toEqual({ ...GOAL, status: 'paused' })
})
it('tells the adapter a set replaces the goal the journal records, whatever its status', async () => {
const journal = await openJournal()
await appendGoalRow(journal, { status: 'complete' })
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true })
await performThreadGoalChange(ctx, {
clientOperationId: 'op-6',
change: { kind: 'set', objective: 'Ship the tests' }
})
await performThreadGoalChange(ctx, {
clientOperationId: 'op-7',
change: { kind: 'status', status: 'paused' }
})
expect(changeThreadGoal.mock.calls).toEqual([
[
expect.objectContaining({
change: { kind: 'set', objective: 'Ship the tests' },
replacesGoal: true
})
],
[
expect.objectContaining({
change: { kind: 'status', status: 'paused' },
replacesGoal: false
})
]
])
})
it('drains accepted provider events before deciding whether a set replaces a goal', async () => {
const journal = await openJournal()
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
// The goal the provider reported is still in the deferred sink when the set arrives.
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }, async () => {
await appendGoalRow(journal, { status: 'active' })
})
await performThreadGoalChange(ctx, {
clientOperationId: 'op-10',
change: { kind: 'set', objective: 'Ship the tests' }
})
expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true }))
})
})
describe('threadGoalPlan replay', () => {
const envelope = {
sessionId: 'session-1',
clientOperationId: 'op-8',
expectedRuntimeFence: 1,
payloadFingerprint: 'fp'
}
it('answers a lost response from the goal the journal records, and runs again otherwise', async () => {
const journal = await openJournal()
const ctx = context(journal, {})
const paused = threadGoalPlan({ envelope, change: { kind: 'status', status: 'paused' } })
const set = threadGoalPlan({ envelope, change: { kind: 'set', objective: 'Ship the parser' } })
const clear = threadGoalPlan({ envelope, change: { kind: 'clear' } })
const unknown = { status: 'unknown' as const }
expect(paused.recoverUnknownFromDurableState).toBe(true)
expect(paused.rerunWhenReplayMissing?.(ctx)).toBe(true)
// Nothing recorded yet: only a clear reads as applied.
expect(paused.replay(ctx, unknown)).toBeNull()
expect(set.replay(ctx, unknown)).toBeNull()
expect(clear.replay(ctx, unknown)).toEqual({ change: 'clear' })
await appendGoalRow(journal, { status: 'paused' })
expect(paused.replay(ctx, unknown)).toEqual({ change: 'status' })
expect(set.replay(ctx, unknown)).toBeNull()
expect(clear.replay(ctx, unknown)).toBeNull()
// A settled success always replays; a refusal never does.
expect(set.replay(ctx, { status: 'succeeded', sessionId: 'session-1' })).toEqual({
change: 'set'
})
expect(
set.replay(ctx, { status: 'failed', code: 'agent_session_operation_invalid' })
).toBeNull()
})
it('reads a set as applied only when the recorded goal is that objective, active', () => {
const change = { kind: 'set' as const, objective: 'Ship the parser' }
expect(journalRecordsThreadGoalChange(GOAL, change)).toBe(true)
expect(journalRecordsThreadGoalChange({ ...GOAL, status: 'paused' }, change)).toBe(false)
expect(journalRecordsThreadGoalChange({ ...GOAL, objective: 'Ship it' }, change)).toBe(false)
expect(journalRecordsThreadGoalChange(null, change)).toBe(false)
})
it('reads a status change as applied only when the recorded goal is in that status', () => {
const pause = { kind: 'status' as const, status: 'paused' as const }
expect(journalRecordsThreadGoalChange({ ...GOAL, status: 'paused' }, pause)).toBe(true)
expect(journalRecordsThreadGoalChange(GOAL, pause)).toBe(false)
expect(
journalRecordsThreadGoalChange(
{ ...GOAL, status: 'paused' },
{ kind: 'status', status: 'active' }
)
).toBe(false)
expect(journalRecordsThreadGoalChange(null, pause)).toBe(false)
})
})
@@ -0,0 +1,123 @@
// `agentSession.threadGoal`: change the provider thread's goal through the same
// admission, ledger and journal path every other session mutation takes.
import type {
AgentJournalItemIdentity,
AgentJournalThreadGoal
} from '../../../shared/agent-session-journal-types'
import type {
AgentSessionMutationEnvelope,
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../shared/agent-session-wire'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
function refused(message: string): TurnOutcome<AgentSessionThreadGoalResult> {
return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } }
}
/** Keyed by the operation, so a replayed set upserts its one objective row. */
function objectiveIdentity(clientOperationId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `thread-goal:${clientOperationId}` }
}
/** Whether the journal's latest goal already shows this change applied. The
* provider reports every goal transition, so this is the durable answer to an
* operation whose response was lost. */
export function journalRecordsThreadGoalChange(
goal: AgentJournalThreadGoal | null,
change: AgentSessionThreadGoalChange
): boolean {
switch (change.kind) {
case 'clear':
return goal === null
case 'status':
return goal !== null && goal.status === change.status
case 'set':
return goal !== null && goal.status === 'active' && goal.objective === change.objective
}
}
export async function performThreadGoalChange(
ctx: AgentSessionTurnContext,
input: { clientOperationId: string; change: AgentSessionThreadGoalChange }
): Promise<TurnOutcome<AgentSessionThreadGoalResult>> {
if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) {
return refused('Goals are unavailable for this chat session.')
}
const { change } = input
const identity = objectiveIdentity(input.clientOperationId)
let replacesGoal = false
if (change.kind === 'set') {
// A goal transition the host accepted but has not journaled yet decides this too.
await ctx.flushStreamedEvents()
// Read before the objective row lands: that row is a message, not a goal transition.
replacesGoal = ctx.journal.threadGoal() !== null
}
// Journal first: an active goal starts provider work at once, and the objective
// must land ahead of that work in the transcript.
if (change.kind === 'set') {
await ctx.journal.appendItem(
identity,
{
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: change.objective }],
sentAs: 'goal'
},
{ fence: ctx.fence }
)
ctx.publish()
}
const withdrawObjective = async (): Promise<void> => {
if (change.kind === 'set') {
// Nothing was sent as a goal.
await ctx.journal.appendTombstone(identity, { fence: ctx.fence })
ctx.publish()
}
}
let result: Awaited<ReturnType<typeof ctx.adapter.changeThreadGoal>>
try {
result = await ctx.adapter.changeThreadGoal({
sessionId: ctx.sessionId,
fence: ctx.fence,
change,
replacesGoal
})
} catch (error) {
await withdrawObjective()
throw error
}
if (!result.ok) {
await withdrawObjective()
return refused(result.rejected)
}
return { ok: true, value: { change: change.kind } }
}
export function threadGoalPlan(params: {
envelope: AgentSessionMutationEnvelope
change: AgentSessionThreadGoalChange
}): MutationPlan<AgentSessionThreadGoalResult> {
const value: AgentSessionThreadGoalResult = { change: params.change.kind }
return {
method: 'agentSession.threadGoal',
fields: { change: params.change },
run: (ctx) =>
performThreadGoalChange(ctx, {
clientOperationId: params.envelope.clientOperationId,
change: params.change
}),
// A lost response is answered from the journal, which the provider keeps
// current; otherwise the change runs again, which is safe for every kind.
recoverUnknownFromDurableState: true,
replay: (ctx, outcome) =>
outcome.status === 'succeeded' ||
(outcome.status === 'unknown' &&
journalRecordsThreadGoalChange(ctx.journal.threadGoal(), params.change))
? value
: null,
rerunWhenReplayMissing: () => true
}
}
@@ -5,7 +5,7 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
type JournalPayloadLimits
} from '../agent-session-journal/journal-payload-bounds'
import { codexGoalRowText } from '../../codex/codex-goal-journal-rows'
import { codexGoalRowText, codexThreadGoalState } from '../../codex/codex-goal-journal-rows'
import {
classifyProviderFrame,
hasTypedProviderFrameTranslator
@@ -139,6 +139,7 @@ export function unhandledProviderFrameJournalItem(
const goalText = provider === 'codex' ? codexGoalRowText(method, payload) : null
const display = message ? boundInlineText(message, limits) : null
const goalDisplay = goalText ? boundInlineText(goalText, limits) : null
const threadGoal = provider === 'codex' ? codexThreadGoalState(method, payload) : null
return {
body: {
kind: 'status',
@@ -147,7 +148,21 @@ export function unhandledProviderFrameJournalItem(
: (goalDisplay?.text ?? display?.text ?? `${provider} · ${kind}`),
...(compaction ? { presentation: 'compaction' } : {}),
...(tone ? { tone } : {}),
providerFrame: { provider, kind, payload: bounded }
providerFrame: { provider, kind, payload: bounded },
...(threadGoal
? {
threadGoal:
threadGoal.state === 'set'
? {
state: 'set' as const,
goal: {
...threadGoal.goal,
objective: boundInlineText(threadGoal.goal.objective, limits).text
}
}
: threadGoal
}
: {})
},
classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive'
}
@@ -69,6 +69,10 @@ export const ADMISSION_METHODS = [
method: 'agentSession.setOption',
params: { envelope: envelope(), key: 'model', value: 'gpt-live' }
},
{
method: 'agentSession.threadGoal',
params: { envelope: envelope(), change: { kind: 'clear' } }
},
{
method: 'agentSession.requestHandoff',
params: { envelope: envelope(), direction: 'to-tui', mode: 'now' }
@@ -183,6 +183,7 @@ export function hostStub(): StructuredAgentSessionHost {
setSessionTabVisibility: vi.fn(async () => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
changeThreadGoal: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
@@ -24,5 +24,6 @@ export {
SessionId,
SetOptionParams,
SubscribeParams,
ThreadGoalParams,
UnsubscribeParams
} from '../../../../shared/rpc-contract/structured-agent-session-params'
@@ -0,0 +1,19 @@
// `agentSession.threadGoal` — set, pause, resume or clear the provider thread's goal.
//
// Additive: an older host answers `method_not_found`, and a client offers the controls only where
// `agentSession.options` reported `threadGoal`, so it never reaches a host that lacks this method.
import { defineMethod } from '../core'
import {
requireStructuredHost as requireHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import { ThreadGoalParams } from './structured-agent-session-schemas'
export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [
defineMethod({
name: 'agentSession.threadGoal',
params: ThreadGoalParams,
handler: async (params, ctx) => requireHost(ctx).changeThreadGoal(callerFor(ctx), params)
})
]
@@ -167,7 +167,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(27)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(28)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -45,6 +45,7 @@ import {
structuredAgentSessionSubscriptionId as subscriptionIdFor
} from './structured-agent-session-subscription-id'
import { STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS } from './structured-agent-session-turn-completion-stream'
import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent-session-thread-goal'
import {
AttachParams,
CancelParams,
@@ -330,5 +331,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
...STRUCTURED_AGENT_SESSION_REVEAL_METHODS,
...STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS,
...STRUCTURED_AGENT_SESSION_STATUS_METHODS,
...STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS
...STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS,
...STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS
]
@@ -34,6 +34,7 @@ import { useNativeChatStructuredComposerSend } from './use-native-chat-structure
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection'
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
import { useNativeChatComposerSubmit } from './use-native-chat-composer-submit'
export type {
NativeChatComposerHandle,
@@ -287,24 +288,18 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
clearImageAttachments,
setNotice
})
const send = useCallback(() => {
if (hasPendingAttachment) {
return
}
if (!structuredTransport) {
sendPty()
} else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) {
sendStructured(draft, imageAttachments)
}
}, [
disabled,
const { send, goalMode } = useNativeChatComposerSubmit({
structuredTransport,
draft,
hasPendingAttachment,
caret,
imageAttachments,
disabled,
sendPty,
sendStructured,
structuredTransport
])
setDraft,
setCaret,
setHistory
})
const interrupt = useCallback(() => {
cancelPendingSends()
@@ -335,13 +330,10 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
setNotice
})
const dispatchPickerCommand = useCallback(
(command: Parameters<typeof dispatchPtyPickerCommand>[0]) => {
if (structuredTransport) {
sendStructured(`/${command.name}`)
return
}
dispatchPtyPickerCommand(command)
},
(command: Parameters<typeof dispatchPtyPickerCommand>[0]) =>
structuredTransport
? sendStructured(`/${command.name}`)
: dispatchPtyPickerCommand(command),
[dispatchPtyPickerCommand, sendStructured, structuredTransport]
)
@@ -351,8 +343,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
draft,
history,
isComposing: imeEnterGesture.isComposing,
completePickerItem: completeItem,
dispatchPickerCommand,
completePickerItem: goalMode.interceptPick(completeItem),
dispatchPickerCommand: goalMode.interceptPick(dispatchPickerCommand),
dismissPicker: dismiss,
interrupt,
send,
@@ -407,7 +399,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
}}
onPaste={handlePaste}
pickerListboxId={picker.listboxId}
onChoosePickerItem={completeItem}
onChoosePickerItem={goalMode.interceptPick(completeItem)}
goalMode={goalMode}
onRetrySkills={picker.retrySkills}
onAcceptMention={() => {
if (autocomplete.mode !== 'mention') {
@@ -7,6 +7,7 @@ import type {
SessionOptionsSurface
} from '../../../../shared/native-chat-session-options'
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import { NativeChatComposerGoalChip } from './NativeChatComposerGoalChip'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
export type NativeChatComposerActionsProps = {
@@ -25,6 +26,8 @@ export type NativeChatComposerActionsProps = {
sessionOptionsSurface: SessionOptionsSurface | null
sessionOptionsSnapshot: SessionOptionDescriptor[]
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
/** Present while the composer is in goal mode; the chip calls it to leave. */
onExitGoalMode?: () => void
}
export function NativeChatComposerActions({
@@ -42,7 +45,8 @@ export function NativeChatComposerActions({
onStop,
sessionOptionsSurface,
sessionOptionsSnapshot,
sessionOptionsPickerRequest
sessionOptionsPickerRequest,
onExitGoalMode
}: NativeChatComposerActionsProps): React.JSX.Element {
const handleCriticalAction = (event: React.MouseEvent<HTMLButtonElement>): void => {
// A double-click commonly lands after the first send has started and the button has
@@ -80,6 +84,7 @@ export function NativeChatComposerActions({
{translate('components.native-chat.composer.attach', 'Attach file')}
</TooltipContent>
</Tooltip>
{onExitGoalMode ? <NativeChatComposerGoalChip onExit={onExitGoalMode} /> : null}
</div>
<div className="ml-auto flex items-center gap-1.5">
{/* Why: keep session controls beside the actions they affect; the
@@ -16,6 +16,8 @@ import type {
} from '../../../../shared/native-chat-session-options'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit'
import { translate } from '@/i18n/i18n'
export type NativeChatComposerFieldProps = {
/** Pane identity published to the drop pipeline so a native file drop lands
@@ -56,6 +58,7 @@ export type NativeChatComposerFieldProps = {
sessionOptionsSurface: SessionOptionsSurface | null
sessionOptionsSnapshot: SessionOptionDescriptor[]
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
goalMode?: NativeChatComposerGoalMode
}
export type NativeChatComposerImageAttachment = {
@@ -127,7 +130,8 @@ export function NativeChatComposerField({
onStop,
sessionOptionsSurface,
sessionOptionsSnapshot,
sessionOptionsPickerRequest
sessionOptionsPickerRequest,
goalMode
}: NativeChatComposerFieldProps): React.JSX.Element {
// Value the IME started from, and whether a programmatic clear was dropped on top of it.
const compositionBaseRef = useRef('')
@@ -254,7 +258,14 @@ export function NativeChatComposerField({
? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}`
: undefined
}
placeholder={nativeChatComposerPlaceholder(hasPty, canSend)}
placeholder={
goalMode?.active
? translate(
'components.native-chat.goal.placeholder',
'Describe your goal, define measurable outcomes for best results'
)
: nativeChatComposerPlaceholder(hasPty, canSend)
}
// Why: coarse-pointer min-height follows the app's touch target convention.
// Editable content grows naturally; the 8lh cap (plus
// py-1) turns further growth into internal scrolling, and scrollbar-sleek
@@ -283,6 +294,7 @@ export function NativeChatComposerField({
sessionOptionsSurface={sessionOptionsSurface}
sessionOptionsSnapshot={sessionOptionsSnapshot}
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
onExitGoalMode={goalMode?.active ? goalMode.exit : undefined}
/>
</div>
</div>
@@ -0,0 +1,37 @@
import { Goal, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
/** Marks the draft as a goal objective; hovering reveals that a click leaves goal mode. */
export function NativeChatComposerGoalChip(props: { onExit: () => void }): React.JSX.Element {
// Not "Clear goal": that is the banner's action on the provider's goal, and this only leaves the mode.
const exitLabel = translate('components.native-chat.goal.exitMode', 'Exit goal mode')
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="xs"
aria-label={exitLabel}
onClick={props.onExit}
className="group/goal-chip"
>
<Goal
aria-hidden
className="group-hover/goal-chip:hidden group-focus-visible/goal-chip:hidden"
/>
<X
aria-hidden
className="hidden group-hover/goal-chip:block group-focus-visible/goal-chip:block"
/>
{translate('components.native-chat.goal.chip', 'Goal')}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{exitLabel}
</TooltipContent>
</Tooltip>
)
}
@@ -9,6 +9,7 @@ import { nativeChatTaskListState } from './native-chat-task-list-state'
import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
import { NativeChatTaskList } from './NativeChatTaskList'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows'
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow'
@@ -134,10 +135,11 @@ export function NativeChatMessageList({
// eslint-disable-next-line react-hooks/exhaustive-deps
[session.agent, session.sessionId]
)
const messages = useMemo(
() => projectNativeChatTaskListFrames(projectMessages(session.messages)),
[projectMessages, session.messages]
)
const messages = useMemo(() => {
const projected = projectNativeChatTaskListFrames(projectMessages(session.messages))
// Structured sessions show goal state in the banner above the composer.
return journalItems ? omitNativeChatThreadGoalRows(projected) : projected
}, [journalItems, projectMessages, session.messages])
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages])
const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages])
const showTypingIndicator = showTurnStatus
@@ -101,3 +101,33 @@ describe('MessageRow control visibility', () => {
expect(screen.queryByRole('button')).toBeNull()
})
})
describe('MessageRow send mode', () => {
function renderUser(sentAs?: NativeChatMessage['sentAs']) {
return render(
<MessageRow
message={{
id: 'message',
role: 'user',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'Ship the parser' }],
...(sentAs ? { sentAs } : {})
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
}
it('marks a user message that was sent as a goal', () => {
renderUser('goal')
expect(screen.getByText('Ship the parser')).toBeInTheDocument()
expect(screen.getByText('Sent as goal')).toBeInTheDocument()
})
it('leaves an ordinary user message unmarked', () => {
renderUser()
expect(screen.queryByText('Sent as goal')).not.toBeInTheDocument()
})
})
@@ -1,4 +1,5 @@
import { memo, useCallback, useRef } from 'react'
import { Goal } from 'lucide-react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
@@ -148,6 +149,12 @@ export const MessageRow = memo(function MessageRow({
/>
)}
</div>
{message.sentAs === 'goal' ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Goal className="size-3" aria-hidden />
<span>{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}</span>
</div>
) : null}
<NativeChatMessageTimestamp
timestamp={message.timestamp}
focusable
@@ -23,6 +23,7 @@ import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-a
import { NativeChatLaunchRetry } from './NativeChatLaunchRetry'
import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch'
import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry'
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
@@ -152,8 +153,12 @@ export function NativeChatStructuredSession(
}
]
: [])
const structuredTransport = useMemo(
() => ({
const structuredTransport = useMemo(() => {
const threadGoal = controller.threadGoal
const setThreadGoalObjective = threadGoal
? (objective: string) => threadGoal.change({ kind: 'set', objective })
: null
return {
send: (text: string, attachments: readonly { id: string; path: string }[]): boolean =>
controller.send(
text,
@@ -172,8 +177,10 @@ export function NativeChatStructuredSession(
},
setOption: controller.setStructuredOption,
conversationCommands: controller.conversationCommands,
runConversationCommand: controller.runConversationCommand
runConversationCommand: controller.runConversationCommand,
...(setThreadGoalObjective ? { setThreadGoalObjective } : {})
}),
...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}),
optionsSurface: controller.optionSurface,
conversationCommands: controller.conversationCommands,
optionSnapshot: controller.optionSnapshot,
@@ -185,16 +192,15 @@ export function NativeChatStructuredSession(
sessionId: props.sessionId,
runtimeEnvironmentId:
props.target.kind === 'local' ? null : (props.target.environmentId ?? null)
}),
[
controller,
fileLinkContext?.worktreeId,
optionPickerRequest,
props.agent,
props.sessionId,
props.target
]
)
}
}, [
controller,
fileLinkContext?.worktreeId,
optionPickerRequest,
props.agent,
props.sessionId,
props.target
])
return (
<div
@@ -317,6 +323,18 @@ export function NativeChatStructuredSession(
backgroundTasks={controller.backgroundTasks}
stopBackgroundTask={controller.stopBackgroundTask}
/>
{!prompt && controller.threadGoal?.goal ? (
<NativeChatThreadGoalBanner
key={props.sessionId}
goal={controller.threadGoal.goal}
pending={controller.threadGoal.pending}
isVisible={props.isVisible}
runningTurn={
controller.turnId === null ? null : { startedAt: controller.workingStartedAt ?? null }
}
onChange={(change) => void controller.threadGoal?.change(change)}
/>
) : null}
{prompt ? null : (
<NativeChatComposer
ref={composerRef}
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
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 { AgentJournalThreadGoal } from '../../../../shared/agent-session-journal-types'
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
import { formatNativeChatThreadGoalElapsed } from './native-chat-thread-goal-presentation'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
const NOW = 10_000_000
function goal(overrides: Partial<AgentJournalThreadGoal> = {}): AgentJournalThreadGoal {
return {
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 60,
createdAt: NOW,
updatedAt: NOW - 7_000,
...overrides
}
}
function renderBanner(
value: AgentJournalThreadGoal,
pending = false,
runningTurn: { startedAt: number | null } | null = { startedAt: null }
) {
const onChange = vi.fn()
const view = render(
<TooltipProvider>
<NativeChatThreadGoalBanner
goal={value}
pending={pending}
isVisible
runningTurn={runningTurn}
onChange={onChange}
/>
</TooltipProvider>
)
return { onChange, view }
}
describe('NativeChatThreadGoalBanner', () => {
it('shows an active goal with its running time and pause control', () => {
vi.useFakeTimers({ now: NOW })
const { onChange } = renderBanner(goal())
expect(screen.getByText('Pursuing goal')).toBeInTheDocument()
expect(screen.getByText('Ship the parser')).toBeInTheDocument()
expect(screen.getByText('• 1m 7s')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Pause goal' }))
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
expect(onChange.mock.calls).toEqual([
[{ kind: 'status', status: 'paused' }],
[{ kind: 'clear' }]
])
expect(screen.queryByRole('button', { name: 'Resume goal' })).toBeNull()
})
it('shows the reported time for an active goal while no turn runs', () => {
vi.useFakeTimers({ now: NOW })
renderBanner(goal(), false, null)
expect(screen.getByText('• 1m 0s')).toBeInTheDocument()
})
it('offers resume on a paused goal and does not count time since the report', () => {
vi.useFakeTimers({ now: NOW })
const { onChange } = renderBanner(goal({ status: 'paused' }))
expect(screen.getByText('Paused goal')).toBeInTheDocument()
expect(screen.getByText('• 1m 0s')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' })
})
it.each([
['blocked', 'Goal blocked'],
['usageLimited', 'Goal limited']
] as const)(
'offers resume on a %s goal, which the provider resumes like a paused one',
(status, label) => {
const { onChange } = renderBanner(goal({ status }))
expect(screen.getByText(label)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Pause goal' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' })
}
)
it('labels a goal whose token budget is spent and offers only clear', () => {
renderBanner(goal({ status: 'budgetLimited' }))
expect(screen.getByText('Goal limited')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Clear goal' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'Pause goal' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Resume goal' })).toBeNull()
})
it('renders nothing for a completed goal', () => {
const { view } = renderBanner(goal({ status: 'complete' }))
expect(view.container).toBeEmptyDOMElement()
})
it('disables the goal commands while one is in flight, but not the expand toggle', () => {
renderBanner(goal(), true)
expect(screen.getByRole('button', { name: 'Clear goal' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Pause goal' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Show full goal' }))
expect(screen.getByRole('button', { name: 'Hide full goal' })).toBeEnabled()
})
})
describe('formatNativeChatThreadGoalElapsed', () => {
it.each([
[25, '25s'],
[67, '1m 7s'],
[7_380, '2h 3m'],
[-4, '0s']
])('formats %d seconds as %s', (seconds, expected) => {
expect(formatNativeChatThreadGoalElapsed(seconds)).toBe(expected)
})
})
@@ -0,0 +1,117 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp, Goal, Pause, Play, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useNow } from '@/hooks/use-now'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { AgentJournalThreadGoal } from '../../../../shared/agent-session-journal-types'
import {
agentSessionThreadGoalElapsedSeconds,
agentSessionThreadGoalStatusChange
} from '../../../../shared/agent-session-thread-goal'
import type { AgentSessionThreadGoalChange } from '../../../../shared/agent-session-wire'
import {
formatNativeChatThreadGoalElapsed,
nativeChatThreadGoalStatusLabel
} from './native-chat-thread-goal-presentation'
function GoalAction(props: {
label: string
disabled: boolean
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={props.label}
disabled={props.disabled}
onClick={props.onClick}
>
{props.children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{props.label}
</TooltipContent>
</Tooltip>
)
}
/** The session's open goal, as a strip attached to the top of the composer. */
export function NativeChatThreadGoalBanner(props: {
goal: AgentJournalThreadGoal
pending: boolean
isVisible: boolean
/** The session's running turn, or null when idle; goal time accrues only while one runs. */
runningTurn: { startedAt: number | null } | null
onChange: (change: AgentSessionThreadGoalChange) => void
}): React.JSX.Element | null {
const { goal, pending, onChange } = 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)
if (label === null) {
return null
}
const elapsed = formatNativeChatThreadGoalElapsed(
agentSessionThreadGoalElapsedSeconds(goal, now, runningTurn)
)
const expandLabel = expanded
? translate('components.native-chat.goal.collapse', 'Hide full goal')
: translate('components.native-chat.goal.expand', 'Show full goal')
return (
// Pulled over the composer's top padding so the strip sits on the input box.
<div
className="relative -mb-2 shrink-0 px-3 sm:px-4"
data-native-chat-thread-goal={goal.status}
>
<div className="mx-auto w-full max-w-4xl px-2">
<div className="flex items-start gap-2 rounded-t-md border border-b-0 border-border bg-muted/30 py-1 pr-1 pl-3 text-xs text-muted-foreground">
<Goal aria-hidden className="mt-1 size-3.5 shrink-0" />
<p className={cn('min-w-0 flex-1 py-0.5', expanded ? 'break-words' : 'truncate')}>
<span className="font-semibold text-foreground">{label}</span>{' '}
<span>{goal.objective}</span>
<span className="tabular-nums">{` • ${elapsed}`}</span>
</p>
<div className="flex shrink-0 items-center">
<GoalAction
label={translate('components.native-chat.goal.clear', 'Clear goal')}
disabled={pending}
onClick={() => onChange({ kind: 'clear' })}
>
<Trash2 className="size-3.5" />
</GoalAction>
{statusChange === 'paused' ? (
<GoalAction
label={translate('components.native-chat.goal.pause', 'Pause goal')}
disabled={pending}
onClick={() => onChange({ kind: 'status', status: 'paused' })}
>
<Pause className="size-3.5" />
</GoalAction>
) : statusChange === 'active' ? (
<GoalAction
label={translate('components.native-chat.goal.resume', 'Resume goal')}
disabled={pending}
onClick={() => onChange({ kind: 'status', status: 'active' })}
>
<Play className="size-3.5" />
</GoalAction>
) : null}
<GoalAction label={expandLabel} disabled={false} onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
</GoalAction>
</div>
</div>
</div>
</div>
)
}
@@ -25,6 +25,8 @@ export type NativeChatStructuredComposerTransport = {
* per-agent catalog, which is what an older host leaves the client with. */
sessionCommands?: readonly AgentSessionSlashCommand[]
worktreeId?: string
/** Present only where the host can set this session's goal. */
threadGoal?: { setObjective: (objective: string) => Promise<boolean> }
onError: (message: string | null) => void
runtime: 'local' | 'remote'
/** The session behind this composer; a real user send relinquishes orchestration ownership. */
@@ -0,0 +1,32 @@
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. */
export function nativeChatThreadGoalStatusLabel(
status: AgentJournalThreadGoalStatus
): string | null {
switch (status) {
case 'active':
return translate('components.native-chat.goal.pursuing', 'Pursuing goal')
case 'paused':
return translate('components.native-chat.goal.paused', 'Paused goal')
case 'blocked':
return translate('components.native-chat.goal.blocked', 'Goal blocked')
case 'usageLimited':
case 'budgetLimited':
return translate('components.native-chat.goal.limited', 'Goal limited')
case 'complete':
return null
}
}
/** Compact elapsed time: `25s`, `1m 7s`, `2h 3m`. */
export function formatNativeChatThreadGoalElapsed(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds))
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows'
const PAYLOAD = { head: '{}', byteLength: 2, digest: 'd'.repeat(64), truncated: false }
function frameRow(id: string, provider: string, kind: string): NativeChatMessage {
return {
id,
role: 'system',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: id, providerFrame: { provider, kind, payload: PAYLOAD } }]
}
}
describe('omitNativeChatThreadGoalRows', () => {
it('drops goal transitions and keeps every other row', () => {
const user: NativeChatMessage = {
id: 'user',
role: 'user',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'Ship the parser' }],
sentAs: 'goal'
}
const warning = frameRow('warning', 'codex', 'notification:warning')
const messages = [
user,
frameRow('set', 'codex', 'notification:thread/goal/updated'),
warning,
frameRow('cleared', 'codex', 'notification:thread/goal/cleared')
]
expect(omitNativeChatThreadGoalRows(messages).map((message) => message.id)).toEqual([
'user',
'warning'
])
})
it('keeps a same-named frame from another provider', () => {
const other = frameRow('other', 'claude', 'notification:thread/goal/updated')
expect(omitNativeChatThreadGoalRows([other])).toEqual([other])
})
})
@@ -0,0 +1,16 @@
import { isAgentSessionThreadGoalFrame } from '../../../../shared/agent-session-thread-goal'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
/** The goal banner shows goal state, so its transitions leave the transcript. The
* journal keeps them; only this view omits them. */
export function omitNativeChatThreadGoalRows(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
return messages.filter(
(message) =>
message.role !== 'system' ||
!message.blocks.some(
(block) => block.type === 'text' && isAgentSessionThreadGoalFrame(block.providerFrame)
)
)
}
@@ -0,0 +1,207 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import type { NativeChatPickerItem } from './native-chat-picker-items'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import { useNativeChatComposerSubmit } from './use-native-chat-composer-submit'
const GOAL_ITEM: NativeChatPickerItem = {
kind: 'command',
id: 'goal',
name: 'goal',
token: '/goal',
skillCollision: false
}
const MODEL_ITEM: NativeChatPickerItem = {
...GOAL_ITEM,
id: 'model',
name: 'model',
token: '/model'
}
function harness(options: {
draft: string
caret?: number
threadGoal?: NativeChatStructuredComposerTransport['threadGoal']
imageAttachments?: NativeChatComposerImageAttachment[]
/** The PTY lane has no structured transport at all. */
lane?: 'pty'
}) {
const onError = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: submit reads only threadGoal and onError.
const structuredTransport = {
onError,
...(options.threadGoal ? { threadGoal: options.threadGoal } : {})
} as unknown as NativeChatStructuredComposerTransport
const calls = {
sendPty: vi.fn(),
sendStructured: vi.fn(),
setDraft: vi.fn(),
setCaret: vi.fn(),
setHistory: vi.fn()
}
const hook = renderHook(
(props: { draft: string; caret: number }) =>
useNativeChatComposerSubmit({
structuredTransport: options.lane === 'pty' ? undefined : structuredTransport,
draft: props.draft,
caret: props.caret,
imageAttachments: options.imageAttachments ?? [],
disabled: false,
...calls
}),
{ initialProps: { draft: options.draft, caret: options.caret ?? options.draft.length } }
)
return { hook, calls, onError }
}
describe('composer goal mode', () => {
it('enters goal mode on a /goal pick and drops the token from the draft', () => {
const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective: vi.fn() } })
const pick = vi.fn()
act(() => hook.result.current.goalMode.interceptPick(pick)(GOAL_ITEM))
expect(pick).not.toHaveBeenCalled()
expect(calls.setDraft).toHaveBeenCalledWith('')
expect(calls.setCaret).toHaveBeenCalledWith(0)
expect(hook.result.current.goalMode.active).toBe(true)
act(() => hook.result.current.goalMode.exit())
expect(hook.result.current.goalMode.active).toBe(false)
})
it('leaves other picks, and /goal on a host without goals, to the picker', () => {
const withGoals = harness({ draft: '/mo', threadGoal: { setObjective: vi.fn() } })
const pick = vi.fn()
act(() => withGoals.hook.result.current.goalMode.interceptPick(pick)(MODEL_ITEM))
expect(pick).toHaveBeenCalledWith(MODEL_ITEM)
const withoutGoals = harness({ draft: '/go' })
act(() => withoutGoals.hook.result.current.goalMode.interceptPick(pick)(GOAL_ITEM))
expect(pick).toHaveBeenCalledWith(GOAL_ITEM)
expect(withoutGoals.hook.result.current.goalMode.active).toBe(false)
})
it('sets the draft as the goal instead of sending it, then leaves goal mode', async () => {
const setObjective = vi.fn(async () => true)
const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } })
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
hook.rerender({ draft: ' Ship the parser ', caret: 0 })
await act(async () => hook.result.current.send())
expect(setObjective).toHaveBeenCalledWith('Ship the parser')
expect(calls.sendStructured).not.toHaveBeenCalled()
expect(calls.setDraft).toHaveBeenLastCalledWith('')
expect(hook.result.current.goalMode.active).toBe(false)
})
it('keeps the draft and goal mode when the goal is refused', async () => {
const setObjective = vi.fn(async () => false)
const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } })
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
calls.setDraft.mockClear()
hook.rerender({ draft: 'Ship the parser', caret: 0 })
await act(async () => hook.result.current.send())
expect(setObjective).toHaveBeenCalledOnce()
expect(calls.setDraft).not.toHaveBeenCalled()
expect(hook.result.current.goalMode.active).toBe(true)
})
it('refuses attachments in goal mode rather than dropping them', () => {
const setObjective = vi.fn(async () => true)
const { hook, onError } = harness({
draft: '/go',
threadGoal: { setObjective },
imageAttachments: [{ id: 'a1', path: '/tmp/shot.png' }]
})
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
hook.rerender({ draft: 'Ship the parser', caret: 0 })
act(() => hook.result.current.send())
expect(setObjective).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith('Remove attachments before setting a goal.')
})
it('enters goal mode from a typed bare /goal, like the pick does', () => {
const { hook, calls } = harness({ draft: '/goal ', threadGoal: { setObjective: vi.fn() } })
act(() => hook.result.current.send())
expect(calls.sendStructured).not.toHaveBeenCalled()
expect(calls.setDraft).toHaveBeenCalledWith('')
expect(hook.result.current.goalMode.active).toBe(true)
// With an objective it is the host command, and without goals it is message text.
const withObjective = harness({ draft: '/goal ship it', threadGoal: { setObjective: vi.fn() } })
act(() => withObjective.hook.result.current.send())
expect(withObjective.calls.sendStructured).toHaveBeenCalledWith('/goal ship it', [])
const withoutGoals = harness({ draft: '/goal' })
act(() => withoutGoals.hook.result.current.send())
expect(withoutGoals.calls.sendStructured).toHaveBeenCalledWith('/goal', [])
expect(withoutGoals.hook.result.current.goalMode.active).toBe(false)
})
it('keeps a bare /goal typed inside goal mode as the entrance, not the objective', () => {
const setObjective = vi.fn(async () => true)
const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } })
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
calls.setDraft.mockClear()
hook.rerender({ draft: '/goal', caret: 5 })
act(() => hook.result.current.send())
expect(setObjective).not.toHaveBeenCalled()
expect(calls.setDraft).toHaveBeenCalledWith('')
expect(hook.result.current.goalMode.active).toBe(true)
})
it('sets the objective a /goal typed inside goal mode names, not the literal command', async () => {
const setObjective = vi.fn(async () => true)
const { hook } = harness({ draft: '/go', threadGoal: { setObjective } })
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
hook.rerender({ draft: '/goal fix the parser ', caret: 0 })
await act(async () => hook.result.current.send())
expect(setObjective).toHaveBeenCalledWith('fix the parser')
expect(hook.result.current.goalMode.active).toBe(false)
})
it('keeps a draft edited while the goal was in flight, and stays in goal mode', async () => {
let settle: (accepted: boolean) => void = () => undefined
const setObjective = vi.fn(() => new Promise<boolean>((resolve) => (settle = resolve)))
const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } })
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
hook.rerender({ draft: 'Ship the parser', caret: 0 })
calls.setDraft.mockClear()
act(() => hook.result.current.send())
hook.rerender({ draft: 'Ship the parser and its tests', caret: 0 })
await act(async () => settle(true))
expect(setObjective).toHaveBeenCalledWith('Ship the parser')
expect(calls.setHistory).toHaveBeenCalledOnce()
expect(calls.setDraft).not.toHaveBeenCalled()
expect(hook.result.current.goalMode.active).toBe(true)
})
it('sends an ordinary message outside goal mode', () => {
const { hook, calls } = harness({ draft: 'hello', threadGoal: { setObjective: vi.fn() } })
act(() => hook.result.current.send())
expect(calls.sendStructured).toHaveBeenCalledWith('hello', [])
})
it('leaves the PTY lane alone: every draft goes to the PTY send, and goal mode never activates', () => {
const { hook, calls } = harness({ draft: '/goal', lane: 'pty' })
act(() => hook.result.current.send())
act(() => hook.result.current.goalMode.interceptPick(calls.setDraft)(GOAL_ITEM))
expect(calls.sendPty).toHaveBeenCalledOnce()
expect(calls.sendStructured).not.toHaveBeenCalled()
expect(calls.setDraft).toHaveBeenCalledExactlyOnceWith(GOAL_ITEM)
expect(hook.result.current.goalMode.active).toBe(false)
})
})
@@ -0,0 +1,146 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { translate } from '@/i18n/i18n'
import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat-picker-items'
import { pushHistory, type HistoryState } from './native-chat-composer-state'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import {
isBareStructuredAgentSessionGoalCommand,
structuredAgentSessionGoalObjective
} from '../../../../shared/structured-agent-session-composer'
const GOAL_COMMAND = 'goal'
export type NativeChatComposerGoalMode = {
/** True while the draft is an objective rather than a message. */
active: boolean
exit: () => void
/** Wraps a picker action so picking `/goal` enters goal mode instead. */
interceptPick: <TItem extends NativeChatPickerItem>(
pick: (item: TItem) => void
) => (item: TItem) => void
}
/**
* What submitting the composer does: set the goal in goal mode, otherwise send
* through the structured or PTY path. Goal mode exists only where the host can
* set a goal, so it is inert everywhere else.
*/
export function useNativeChatComposerSubmit(args: {
structuredTransport?: NativeChatStructuredComposerTransport
draft: string
caret: number
imageAttachments: readonly NativeChatComposerImageAttachment[]
disabled: boolean
sendPty: () => void
sendStructured: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void
setDraft: (value: string) => void
setCaret: (caret: number) => void
setHistory: (updater: (previous: HistoryState) => HistoryState) => void
}): { send: () => void; goalMode: NativeChatComposerGoalMode } {
const { caret, disabled, draft, imageAttachments, sendPty, sendStructured } = args
const { setCaret, setDraft, setHistory, structuredTransport } = args
const threadGoal = structuredTransport?.threadGoal
const [entered, setEntered] = useState(false)
const active = entered && threadGoal !== undefined
const interceptPick = useCallback(
<TItem extends NativeChatPickerItem>(pick: (item: TItem) => void) =>
(item: TItem) => {
if (!threadGoal || item.kind !== 'command' || item.name !== GOAL_COMMAND) {
pick(item)
return
}
// Drop the `/goal ` token the pick would have inserted; the chip replaces it.
const inserted = applyPickerSuggestion(draft, caret, item)
const tokenStart = inserted.caret - item.token.length - 1
setDraft(inserted.draft.slice(0, tokenStart) + inserted.draft.slice(inserted.caret))
setCaret(tokenStart)
setEntered(true)
},
[caret, draft, setCaret, setDraft, threadGoal]
)
// Setting a goal is a host round trip; the draft is cleared only if it is still
// the one that was submitted, as with any other host command.
const composition = useRef(draft)
useLayoutEffect(() => {
composition.current = draft
}, [draft])
// In-flight changes are serialized by the session's goal controller, which
// answers false to a second submit while the first is unsettled.
const setGoal = useCallback(() => {
const objective = structuredAgentSessionGoalObjective(draft)
if (!threadGoal || !structuredTransport || objective === '') {
return
}
if (imageAttachments.length > 0) {
structuredTransport.onError(
translate(
'components.native-chat.goal.attachmentsUnsupported',
'Remove attachments before setting a goal.'
)
)
return
}
void threadGoal.setObjective(objective).then((accepted) => {
if (!accepted) {
return
}
structuredTransport.onError(null)
setHistory((previous) => pushHistory(previous, draft))
if (composition.current !== draft) {
return
}
setDraft('')
setCaret(0)
setEntered(false)
})
}, [
draft,
imageAttachments.length,
setCaret,
setDraft,
setHistory,
structuredTransport,
threadGoal
])
const send = useCallback(() => {
if (imageAttachments.some((attachment) => attachment.pending)) {
return
}
if (threadGoal && structuredTransport && isBareStructuredAgentSessionGoalCommand(draft)) {
// Same entrance as picking `/goal`: the token becomes the chip. Typed inside
// goal mode it is still the entrance, never an objective.
setDraft('')
setCaret(0)
setEntered(true)
} else if (active) {
if (!disabled) {
setGoal()
}
} else if (!structuredTransport) {
sendPty()
} else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) {
sendStructured(draft, imageAttachments)
}
}, [
active,
disabled,
draft,
imageAttachments,
sendPty,
sendStructured,
setCaret,
setDraft,
setGoal,
structuredTransport,
threadGoal
])
const exit = useCallback(() => setEntered(false), [])
const goalMode = useMemo(() => ({ active, exit, interceptPick }), [active, exit, interceptPick])
return { send, goalMode }
}
@@ -14,7 +14,10 @@ vi.mock('@/lib/worker-terminal-takeover-report', () => ({
const ATTACHMENT = { id: 'a1', path: '/tmp/shot.png' } as NativeChatComposerImageAttachment
function harness(agent: AgentType) {
function harness(
agent: AgentType,
threadGoal?: NativeChatStructuredComposerTransport['threadGoal']
) {
const structuredTransport = {
send: vi.fn(() => true),
dispatchCommand: (text: string) =>
@@ -32,6 +35,9 @@ function harness(agent: AgentType) {
sessionId: 'session-test',
runtimeEnvironmentId: null
} as unknown as NativeChatStructuredComposerTransport
if (threadGoal) {
structuredTransport.threadGoal = threadGoal
}
const { result } = renderHook(() =>
useNativeChatStructuredComposerSend({
agent,
@@ -79,4 +85,15 @@ describe('attachment guard follows what the host claims', () => {
'Remove attachments before using a chat-session command.'
)
})
it('refuses attachments on /goal where the host sets the goal, since no message is sent', () => {
const setObjective = vi.fn(async () => true)
const { send, structuredTransport } = harness('codex', { setObjective })
send('/goal ship the fix')
expect(structuredTransport.onError).toHaveBeenCalledWith(
'Remove attachments before using a chat-session command.'
)
expect(setObjective).not.toHaveBeenCalled()
expect(structuredTransport.send).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,10 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
import { reportStructuredSessionUserInput } from '@/lib/worker-terminal-takeover-report'
import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import {
isStructuredAgentSessionComposerCommand,
isStructuredAgentSessionGoalCommand
} from '../../../../shared/structured-agent-session-composer'
import type { AgentType } from '../../../../shared/agent-status-types'
import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch'
import { pushHistory, type HistoryState } from './native-chat-composer-state'
@@ -45,7 +48,10 @@ export function useNativeChatStructuredComposerSend({
if (!structuredTransport) {
return
}
if (attachments.length > 0 && isStructuredAgentSessionComposerCommand(text, agent)) {
const hostCommand =
isStructuredAgentSessionComposerCommand(text, agent) ||
(structuredTransport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text))
if (attachments.length > 0 && hostCommand) {
structuredTransport.onError('Remove attachments before using a chat-session command.')
return
}
@@ -66,7 +72,7 @@ export function useNativeChatStructuredComposerSend({
)
setHistory((previous) => pushHistory(previous, text))
if (
isStructuredAgentSessionComposerCommand(text, agent) &&
hostCommand &&
(composition.current.draft !== submitted.draft ||
composition.current.imageAttachments !== submitted.imageAttachments)
) {
@@ -37,6 +37,7 @@ export function useStructuredAgentSessionOptions(args: {
const [conversationSupport, setConversationSupport] = useState<{
sessionId: string
commands: readonly AgentSessionConversationCommand[]
threadGoal: AgentSessionOptionsResult['threadGoal']
} | null>(null)
const [optionState, setOptionState] = useState(() =>
createStructuredAgentSessionOptionState(agent)
@@ -76,7 +77,11 @@ export function useStructuredAgentSessionOptions(args: {
})
.then((result) => {
if (!stale && optionMutationGeneration.current === readGeneration) {
setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] })
setConversationSupport({
sessionId,
commands: result.conversationCommands ?? [],
threadGoal: result.threadGoal
})
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
@@ -197,6 +202,11 @@ export function useStructuredAgentSessionOptions(args: {
transportEnabled && conversationSupport?.sessionId === sessionId
? conversationSupport.commands
: [],
/** Absent unless this host and session can change the goal. */
threadGoal:
transportEnabled && conversationSupport?.sessionId === sessionId
? conversationSupport.threadGoal
: undefined,
optionSnapshot: visibleOptionSnapshot,
optionSurface,
setStructuredOption
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalThreadGoal
} from '../../../../shared/agent-session-journal-types'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal'
const GOAL: AgentJournalThreadGoal = {
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1_000,
updatedAt: 1_000
}
function goalRow(sequence: number, goal: AgentJournalThreadGoal): AgentJournalRenderItem {
return {
itemId: `goal-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: {
kind: 'status',
text: `Goal set: ${goal.objective}`,
threadGoal: { state: 'set', goal }
}
}
}
function clearedRow(sequence: number): AgentJournalRenderItem {
return {
itemId: `goal-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'status', text: 'Goal cleared', threadGoal: { state: 'cleared' } }
}
}
type MutateCall = (...args: unknown[]) => Promise<unknown>
/** The hook reads only whether an answer is null, so a mock need not carry the generic. */
function mutateWith(answer: MutateCall): {
mutate: StructuredAgentSessionMutate
calls: MutateCall
} {
const calls = vi.fn(answer)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook never narrows the answer beyond null.
return { mutate: calls as unknown as StructuredAgentSessionMutate, calls }
}
function harness(options: {
journalItems?: readonly AgentJournalRenderItem[]
support?: { current: AgentJournalThreadGoal | null }
mutate?: StructuredAgentSessionMutate
}) {
const mutate = options.mutate ?? mutateWith(async () => null).mutate
return renderHook(() =>
useStructuredAgentSessionThreadGoal({
journalItems: options.journalItems ?? [],
support: options.support,
mutate
})
)
}
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('reads the goal off the loaded window, and off the host answer only when the window has none', () => {
const older = { ...GOAL, objective: 'Older goal' }
const fromWindow = harness({ journalItems: [goalRow(1, GOAL)], support: { current: older } })
expect(fromWindow.result.current?.goal).toEqual(GOAL)
const fromHost = harness({ support: { current: older } })
expect(fromHost.result.current?.goal).toEqual(older)
// A clear in the window outranks a host answer read before it.
const cleared = harness({ journalItems: [clearedRow(2)], support: { current: older } })
expect(cleared.result.current?.goal).toBeNull()
})
it('serializes changes: a second submit while one is unsettled is answered false, not sent', async () => {
let settle: (value: { change: 'set' } | null) => void = () => undefined
const { mutate, calls } = mutateWith(
() => new Promise<{ change: 'set' } | null>((resolve) => (settle = resolve))
)
const { result } = harness({ support: { current: null }, mutate })
let first: Promise<boolean> = Promise.resolve(false)
let second: Promise<boolean> = Promise.resolve(false)
act(() => {
first = result.current!.change({ kind: 'set', objective: 'Ship the parser' })
second = result.current!.change({ kind: 'set', objective: 'Ship the parser' })
})
await expect(second).resolves.toBe(false)
expect(calls).toHaveBeenCalledTimes(1)
expect(calls).toHaveBeenCalledWith('agentSession.threadGoal', 'agentSession.threadGoal', {
change: { kind: 'set', objective: 'Ship the parser' }
})
expect(result.current?.pending).toBe(true)
await act(async () => settle({ change: 'set' }))
await expect(first).resolves.toBe(true)
expect(result.current?.pending).toBe(false)
})
it('answers false for a refused or unsent change and accepts the next one', async () => {
const { mutate, calls } = mutateWith(async () => null)
const { result } = harness({ support: { current: GOAL }, mutate })
await act(async () => {
await expect(result.current!.change({ kind: 'clear' })).resolves.toBe(false)
})
await act(async () => {
await expect(result.current!.change({ kind: 'clear' })).resolves.toBe(false)
})
expect(calls).toHaveBeenCalledTimes(2)
expect(result.current?.pending).toBe(false)
})
})
@@ -0,0 +1,56 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type {
AgentJournalRenderItem,
AgentJournalThreadGoal
} from '../../../../shared/agent-session-journal-types'
import { currentAgentSessionThreadGoal } from '../../../../shared/agent-session-thread-goal'
import type {
AgentSessionOptionsResult,
AgentSessionThreadGoalChange,
AgentSessionThreadGoalResult
} from '../../../../shared/agent-session-wire'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
export type StructuredAgentSessionThreadGoal = {
goal: AgentJournalThreadGoal | null
pending: 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. */
export function useStructuredAgentSessionThreadGoal(args: {
journalItems: readonly AgentJournalRenderItem[]
support: AgentSessionOptionsResult['threadGoal']
mutate: StructuredAgentSessionMutate
}): StructuredAgentSessionThreadGoal | null {
const { journalItems, mutate, support } = 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 loaded = useMemo(() => currentAgentSessionThreadGoal(journalItems), [journalItems])
const goal = loaded === undefined ? (support?.current ?? null) : loaded
const change = useCallback(
async (next: AgentSessionThreadGoalChange): Promise<boolean> => {
if (pendingRef.current) {
return false
}
pendingRef.current = true
setPending(true)
try {
const result = await mutate<AgentSessionThreadGoalResult>(
'agentSession.threadGoal',
'agentSession.threadGoal',
{ change: next }
)
return result !== null
} finally {
pendingRef.current = false
setPending(false)
}
},
[mutate]
)
return support ? { goal, pending, change } : null
}
@@ -17,6 +17,7 @@ import { useStructuredAgentSessionMessages } from './use-structured-agent-sessio
import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state'
import { useStructuredAgentSessionTransport } from './use-structured-agent-session-transport'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
@@ -39,17 +40,22 @@ export function useStructuredAgentSession(args: {
})
const commandPending = useRef(false)
const transportState = useStructuredAgentSessionTransportState(state, transportEnabled)
const { conversationCommands, optionSnapshot, optionSurface, setStructuredOption } =
useStructuredAgentSessionOptions({
agent,
sessionId,
target,
transportEnabled,
providerVisible,
fence: state.fence,
turnId: transportState.turnId,
mutate
})
const {
conversationCommands,
optionSnapshot,
optionSurface,
setStructuredOption,
threadGoal: threadGoalSupport
} = useStructuredAgentSessionOptions({
agent,
sessionId,
target,
transportEnabled,
providerVisible,
fence: state.fence,
turnId: transportState.turnId,
mutate
})
const outboxController = useStructuredAgentSessionOutbox({
sessionId,
target,
@@ -57,6 +63,12 @@ export function useStructuredAgentSession(args: {
submissions: transportState.submissions
})
const threadGoal = useStructuredAgentSessionThreadGoal({
journalItems: transportState.journalItems,
support: threadGoalSupport,
mutate
})
const prompts = pendingStructuredSessionPrompts(transportState.journalItems)
const { outbox } = outboxController
const messages = useStructuredAgentSessionMessages(
@@ -131,6 +143,7 @@ export function useStructuredAgentSession(args: {
optionSnapshot,
optionSurface,
sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined,
setStructuredOption
setStructuredOption,
threadGoal
}
}
+17 -1
View File
@@ -17579,7 +17579,23 @@
"title": "Drop to attach to this chat",
"subtitle": "Files are added to your message as paths the agent can read."
},
"copyCode": "Copy code"
"copyCode": "Copy code",
"goal": {
"placeholder": "Describe your goal, define measurable outcomes for best results",
"clear": "Clear goal",
"chip": "Goal",
"exitMode": "Exit goal mode",
"sentAsGoal": "Sent as goal",
"collapse": "Hide full goal",
"expand": "Show full goal",
"pause": "Pause goal",
"resume": "Resume goal",
"pursuing": "Pursuing goal",
"paused": "Paused goal",
"blocked": "Goal blocked",
"limited": "Goal limited",
"attachmentsUnsupported": "Remove attachments before setting a goal."
}
},
"tab": {
"bar": {
@@ -382,3 +382,82 @@ describe('optional tool annotations', () => {
).toBe(false)
})
})
describe('thread goal fields', () => {
const GOAL = {
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1_000,
updatedAt: 1_000
} as const
it('admits a user message sent as a goal and a typed goal transition', () => {
const bodies: AgentJournalItemBody[] = [
{
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Ship the parser' }],
sentAs: 'goal'
},
{
kind: 'status',
text: 'Goal set: Ship the parser',
threadGoal: { state: 'set', goal: GOAL }
},
{ kind: 'status', text: 'Goal cleared', threadGoal: { state: 'cleared' } }
]
for (const body of bodies) {
expect(isAdmissibleAgentJournalItemBody(body)).toBe(true)
}
expect(isAdmissibleAgentJournalMessageBody(bodies[0])).toBe(true)
})
it('keeps a send mode or goal state a newer build writes admissible', () => {
expect(
isAdmissibleAgentJournalItemBody({
kind: 'message',
role: 'user',
blocks: [],
sentAs: 'scheduled'
})
).toBe(true)
expect(
isAdmissibleAgentJournalItemBody({
kind: 'status',
text: 'Goal archived',
threadGoal: { state: 'archived' }
})
).toBe(true)
expect(
isAdmissibleAgentJournalItemBody({
kind: 'status',
text: 'Goal set',
threadGoal: { state: 'set', goal: { ...GOAL, status: 'snoozed' } }
})
).toBe(true)
})
it('rejects a malformed send mode or goal snapshot', () => {
for (const body of [
{ kind: 'message', role: 'user', blocks: [], sentAs: 5 },
{ kind: 'message', role: 'user', blocks: [], sentAs: '' },
{ kind: 'status', text: 'Goal set', threadGoal: { state: 'set' } },
{
kind: 'status',
text: 'Goal set',
threadGoal: { state: 'set', goal: { ...GOAL, objective: null } }
},
{
kind: 'status',
text: 'Goal set',
threadGoal: { state: 'set', goal: { ...GOAL, timeUsedSeconds: 'soon' } }
},
{ kind: 'status', text: 'Goal set', threadGoal: 'set' }
]) {
expect(isAdmissibleAgentJournalItemBody(body)).toBe(false)
}
})
})
+24 -2
View File
@@ -165,9 +165,30 @@ const ApprovalSubject = z.object({
const MessageBody = z.object({
kind: z.literal('message'),
role: z.string().min(1),
blocks: z.array(Block)
blocks: z.array(Block),
// Open like roles: a send mode a newer build writes must not turn the row malformed.
sentAs: z.string().min(1).optional()
})
const ThreadGoal = z.object({
objective: z.string(),
status: z.string().min(1),
tokenBudget: z.number().finite().nullable(),
tokensUsed: z.number().finite(),
timeUsedSeconds: z.number().finite(),
createdAt: z.number().finite(),
updatedAt: z.number().finite()
})
/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */
const ThreadGoalState = z.union([
z.discriminatedUnion('state', [
z.object({ state: z.literal('set'), goal: ThreadGoal }),
z.object({ state: z.literal('cleared') })
]),
z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state))
])
export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
MessageBody,
z.object({
@@ -219,7 +240,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
durationMs: z.number().finite().nonnegative().optional()
})
.optional(),
providerFrame: ProviderFrame.optional()
providerFrame: ProviderFrame.optional(),
threadGoal: ThreadGoalState.optional()
}),
z.object({
kind: z.literal('turn'),
+40
View File
@@ -85,10 +85,19 @@ export type AgentJournalBoundedPayload = {
// ─── Render-model items ─────────────────────────────────────────────────────
/** How a user message reached the provider when it was not an ordinary turn
* input. Persisted and open for growth: a reader that cannot place a value
* renders an ordinary message. */
export const AGENT_JOURNAL_MESSAGE_SEND_MODES = ['goal'] as const
export type AgentJournalMessageSendMode = (typeof AGENT_JOURNAL_MESSAGE_SEND_MODES)[number]
export type AgentJournalMessageItem = {
kind: 'message'
role: NativeChatRole
blocks: NativeChatBlock[]
/** Absent ⇒ an ordinary turn input. `goal` ⇒ the text was set as the thread
* goal's objective, and the provider pursues it without a turn of its own. */
sentAs?: AgentJournalMessageSendMode
}
export type AgentJournalToolCallState = 'running' | 'completed' | 'failed'
@@ -213,6 +222,35 @@ export type AgentJournalTurnLifecycle = {
durationMs?: number
}
/** Provider thread-goal lifecycle. Open like other persisted vocabularies: a
* status a newer provider reports must not turn a row malformed. */
export const AGENT_JOURNAL_THREAD_GOAL_STATUSES = [
'active',
'paused',
'blocked',
'usageLimited',
'budgetLimited',
'complete'
] as const
export type AgentJournalThreadGoalStatus = (typeof AGENT_JOURNAL_THREAD_GOAL_STATUSES)[number]
/** The provider's goal as last journaled. Timestamps are epoch ms on the
* provider's clock; counters are as of `updatedAt`. */
export type AgentJournalThreadGoal = {
objective: string
status: AgentJournalThreadGoalStatus
tokenBudget: number | null
tokensUsed: number
timeUsedSeconds: number
createdAt: number
updatedAt: number
}
/** A goal transition in typed form, so readers never parse a bounded frame head. */
export type AgentJournalThreadGoalState =
| { state: 'set'; goal: AgentJournalThreadGoal }
| { state: 'cleared' }
export type AgentJournalStatusItem = {
kind: 'status'
text: string
@@ -230,6 +268,8 @@ export type AgentJournalStatusItem = {
kind: string
payload: AgentJournalBoundedPayload
}
/** Present on thread-goal transitions; absent on rows from older hosts. */
threadGoal?: AgentJournalThreadGoalState
}
/** The durable record of one root turn. `running` exposes cancellation while
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem,
AgentJournalThreadGoal
} from './agent-session-journal-types'
import {
agentSessionThreadGoalElapsedSeconds,
agentSessionThreadGoalStatusChange,
currentAgentSessionThreadGoal,
currentAgentSessionThreadGoalBySequence,
isAgentSessionThreadGoalOpen
} from './agent-session-thread-goal'
const PAYLOAD = { head: '{"goal":', byteLength: 4096, digest: 'd'.repeat(64), truncated: true }
function goal(overrides: Partial<AgentJournalThreadGoal> = {}): AgentJournalThreadGoal {
return {
objective: 'Ship the parser',
status: 'active',
tokenBudget: null,
tokensUsed: 10,
timeUsedSeconds: 30,
createdAt: 1_000_000,
updatedAt: 1_000_000,
...overrides
}
}
function row(
sequence: number,
body: AgentJournalItemBody,
extra: Partial<AgentJournalRenderItem> = {}
): AgentJournalRenderItem {
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body, ...extra }
}
function goalRow(sequence: number, state: 'set' | 'cleared', value = goal()) {
return row(sequence, {
kind: 'status',
text: state === 'set' ? `Goal set: ${value.objective}` : 'Goal cleared',
providerFrame: {
provider: 'codex',
kind: `notification:thread/goal/${state === 'set' ? 'updated' : 'cleared'}`,
payload: PAYLOAD
},
threadGoal: state === 'set' ? { state: 'set', goal: value } : { state: 'cleared' }
})
}
describe('current thread goal', () => {
it('reports no answer when no row records a goal transition', () => {
expect(
currentAgentSessionThreadGoal([
row(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }),
row(2, { kind: 'status', text: 'Context compacted' })
])
).toBeUndefined()
})
it('reads the last goal row of a rendered snapshot, past later rows of other kinds', () => {
const paused = goal({ status: 'paused' })
expect(
currentAgentSessionThreadGoal([
goalRow(1, 'set'),
goalRow(2, 'set', paused),
row(3, { kind: 'status', text: 'Context compacted' })
])
).toEqual(paused)
})
it('takes the latest transition by sequence for items held unordered', () => {
const paused = goal({ status: 'paused' })
const unordered = new Map([
['b', goalRow(5, 'set', paused)],
['a', goalRow(2, 'set')]
])
expect(currentAgentSessionThreadGoalBySequence(unordered.values())).toEqual(paused)
expect(currentAgentSessionThreadGoalBySequence([])).toBeUndefined()
})
it('answers null once the latest transition cleared the goal', () => {
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), goalRow(2, 'cleared')])).toBeNull()
})
it('answers null for a row written before the typed snapshot existed', () => {
const legacy = row(3, {
kind: 'status',
text: 'Goal set: Ship the parser',
providerFrame: {
provider: 'codex',
kind: 'notification:thread/goal/updated',
payload: PAYLOAD
}
})
// The legacy row still supersedes the older typed one; it just cannot be read.
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), legacy])).toBeNull()
})
it('ignores a subagent goal and a status this build cannot place', () => {
const subagent = { ...goalRow(4, 'cleared'), agentId: 'child-1' }
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), subagent])).toEqual(goal())
// A newer provider status must not read as a known one.
const future = goalRow(5, 'set', Object.assign(goal(), { status: 'snoozed' }))
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), future])).toBeNull()
})
})
describe('thread goal presentation facts', () => {
it('keeps every status but complete open', () => {
expect(isAgentSessionThreadGoalOpen(goal({ status: 'blocked' }))).toBe(true)
expect(isAgentSessionThreadGoalOpen(goal({ status: 'complete' }))).toBe(false)
expect(isAgentSessionThreadGoalOpen(null)).toBe(false)
})
it('pauses only an active goal, and resumes a paused, blocked or usage-limited one', () => {
expect(agentSessionThreadGoalStatusChange('active')).toBe('paused')
expect(agentSessionThreadGoalStatusChange('paused')).toBe('active')
expect(agentSessionThreadGoalStatusChange('blocked')).toBe('active')
expect(agentSessionThreadGoalStatusChange('usageLimited')).toBe('active')
// A spent budget is not a pause: the provider will not resume it.
expect(agentSessionThreadGoalStatusChange('budgetLimited')).toBeNull()
expect(agentSessionThreadGoalStatusChange('complete')).toBeNull()
})
it('adds time only while an active goal has a turn running', () => {
const now = 1_000_000 + 7_500
const running = { startedAt: null }
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, null)).toBe(30)
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, running)).toBe(37)
expect(agentSessionThreadGoalElapsedSeconds(goal({ status: 'paused' }), now, running)).toBe(30)
// A report older than the turn counts from the turn's start, not across the idle gap.
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, { startedAt: now - 2_000 })).toBe(32)
// A provider clock ahead of this one never subtracts time.
expect(
agentSessionThreadGoalElapsedSeconds(goal({ updatedAt: now + 5_000 }), now, running)
).toBe(30)
})
})
+126
View File
@@ -0,0 +1,126 @@
// The session's current thread goal, derived from the journal rather than stored
// beside it. The latest goal transition in journal order is the whole answer.
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import {
AGENT_JOURNAL_THREAD_GOAL_STATUSES,
type AgentJournalItemBody,
type AgentJournalRenderItem,
type AgentJournalThreadGoal,
type AgentJournalThreadGoalStatus
} from './agent-session-journal-types'
const GOAL_FRAME_KINDS = new Set([
'notification:thread/goal/updated',
'notification:thread/goal/cleared'
])
type GoalCandidate = Pick<AgentJournalRenderItem, 'sequence' | 'body' | 'agentId'>
export function isAgentJournalThreadGoalStatus(
value: string
): value is AgentJournalThreadGoalStatus {
return AGENT_JOURNAL_THREAD_GOAL_STATUSES.some((status) => status === value)
}
/** Whether a provider frame reports a goal transition. */
export function isAgentSessionThreadGoalFrame(
frame: { provider: string; kind: string } | undefined
): boolean {
return frame?.provider === 'codex' && GOAL_FRAME_KINDS.has(frame.kind)
}
/** Whether a row records a goal transition. Rows written before the typed
* snapshot existed are still recognized by their frame. */
export function isAgentJournalThreadGoalRow(body: AgentJournalItemBody): boolean {
return (
body.kind === 'status' &&
(body.threadGoal !== undefined || isAgentSessionThreadGoalFrame(body.providerFrame))
)
}
function goalFromRow(body: AgentJournalItemBody): AgentJournalThreadGoal | null {
if (body.kind !== 'status' || body.threadGoal?.state !== 'set') {
// Cleared, an unknown state, or a legacy row whose payload may be truncated.
return null
}
return isAgentJournalThreadGoalStatus(body.threadGoal.goal.status) ? body.threadGoal.goal : null
}
function isGoalTransition(item: GoalCandidate): boolean {
return isRootAgentJournalItem(item) && isAgentJournalThreadGoalRow(item.body)
}
/**
* The current goal as far as these rows can tell: `undefined` when none of them
* records a goal transition, otherwise the latest one's goal, or null when it
* cleared the goal or cannot be read. Scans backwards because every caller passes
* a rendered snapshot, which is already in sequence order; a revision keeps its
* row's sequence, so the last goal row is the answer.
*/
export function currentAgentSessionThreadGoal(
items: readonly GoalCandidate[]
): AgentJournalThreadGoal | null | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
if (item && isGoalTransition(item)) {
return goalFromRow(item.body)
}
}
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
for (const item of items) {
if (isGoalTransition(item) && (latest === null || item.sequence > latest.sequence)) {
latest = item
}
}
return latest === null ? undefined : goalFromRow(latest.body)
}
/** Goal statuses that still describe work in progress, so readers keep them in view. */
export function isAgentSessionThreadGoalOpen(goal: AgentJournalThreadGoal | null): boolean {
return goal !== null && goal.status !== 'complete'
}
/** The status change a goal in this status accepts, or null when it accepts none:
* a stalled or usage-limited goal resumes the same way a paused one does, while
* a spent token budget and a completed goal can only be cleared or replaced. */
export function agentSessionThreadGoalStatusChange(
status: AgentJournalThreadGoalStatus
): 'active' | 'paused' | null {
switch (status) {
case 'active':
return 'paused'
case 'paused':
case 'blocked':
case 'usageLimited':
return 'active'
case 'budgetLimited':
case 'complete':
return null
}
}
/**
* Seconds of goal work. The provider's `timeUsedSeconds` is exact as of `updatedAt`;
* only an active goal with a turn running accrues more, counted from whichever of
* that report and the turn's start is later.
*/
export function agentSessionThreadGoalElapsedSeconds(
goal: AgentJournalThreadGoal,
now: number,
runningTurn: { startedAt: number | null } | null
): number {
const reported = Math.max(0, goal.timeUsedSeconds)
if (goal.status !== 'active' || runningTurn === null) {
return reported
}
const since = Math.max(goal.updatedAt, runningTurn.startedAt ?? goal.updatedAt)
return reported + Math.max(0, Math.floor((now - since) / 1000))
}
+20
View File
@@ -20,6 +20,7 @@ import type {
AgentJournalResetReason,
AgentJournalResolution,
AgentJournalSubmission,
AgentJournalThreadGoal,
AgentJournalTurnOutcome
} from './agent-session-journal-types'
import {
@@ -379,11 +380,30 @@ 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 = {
rewind?: AgentSessionRewindSupport
conversationCommands?: readonly AgentSessionConversationCommand[]
/** Present only where this session can change its goal, so a host without
* `agentSession.threadGoal` never offers the controls. `current` is the
* latest goal the whole journal records, for a client whose loaded page
* starts after it. */
threadGoal?: { current: AgentJournalThreadGoal | null }
models: AgentSessionModelOption[]
/** Session/account/transport support. Absent means unknown, never unsupported. */
fastModeSupport?: AgentSessionFastModeSupport
+3
View File
@@ -10,6 +10,7 @@ import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskRunState
} from './agent-session-background-task-wire'
import type { AgentJournalMessageSendMode } from './agent-session-journal-types'
import type { AgentType } from './agent-status-types'
import type { NativeChatToolMetadata } from './native-chat-tool-identity'
@@ -196,6 +197,8 @@ export type NativeChatMessage = {
/** Optional explicit turn key. When present, two messages with the same
* `turnId` are treated as the same turn for dedup regardless of `id`. */
turnId?: string
/** How a user message was delivered when it was not an ordinary prompt. */
sentAs?: AgentJournalMessageSendMode
}
export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const
+2
View File
@@ -480,6 +480,7 @@ import {
SendParams,
SetOptionParams,
SubscribeParams,
ThreadGoalParams,
UnsubscribeParams
} from './structured-agent-session-params'
import { TerminalAdoptOrphans } from './terminal-orphan-params'
@@ -589,6 +590,7 @@ export const RPC_PARAMS_BY_METHOD = {
'agentSession.subscribe': SubscribeParams,
'agentSession.subscribeStatus': null,
'agentSession.subscribeTurnCompletions': null,
'agentSession.threadGoal': ThreadGoalParams,
'agentSession.unsubscribe': UnsubscribeParams,
'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch,
'agentTeams.tmuxCompat': AgentTeamsTmuxCompat,
@@ -4,7 +4,8 @@ import { normalizeExecutionHostId } from '../execution-host'
import {
AGENT_SESSION_ID_MAX_LENGTH,
AGENT_SESSION_HISTORY_DIRECTIONS,
AGENT_SESSION_HISTORY_MAX_LIMIT
AGENT_SESSION_HISTORY_MAX_LIMIT,
AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH
} from '../agent-session-wire'
export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH
@@ -223,6 +224,25 @@ export const ConversationCommandParams = z
})
.strict()
export const ThreadGoalParams = z
.object({
envelope: MutationEnvelope,
change: z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('set'),
objective: z
.string()
.max(AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH)
.refine((value) => value.trim().length > 0, 'Objective is empty')
})
.strict(),
z.object({ kind: z.literal('status'), status: z.enum(['active', 'paused']) }).strict(),
z.object({ kind: z.literal('clear') }).strict()
])
})
.strict()
/** One surface's claim on one session. The id names the surface, not the client: two chat views
* looking at the same session are two holders, and either leaving must not release
* the other's. */
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
dispatchStructuredAgentSessionComposerCommand,
isStructuredAgentSessionComposerCommand,
structuredAgentSessionGoalObjective,
structuredSlashCommands
} from './structured-agent-session-composer'
@@ -193,6 +194,48 @@ describe('agent-implemented commands pass through to the agent', () => {
).toEqual(PASSED_THROUGH)
})
it('sets the goal through the host where the host can, instead of sending prose', async () => {
const setThreadGoalObjective = vi.fn(async () => true)
expect(
await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix ', {
...controller,
agent: 'codex',
setThreadGoalObjective
})
).toEqual({ handled: true, accepted: true, error: null })
expect(setThreadGoalObjective).toHaveBeenCalledWith('ship the fix')
// A refused goal keeps the draft; the session error surface explains why.
setThreadGoalObjective.mockResolvedValueOnce(false)
expect(
await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', {
...controller,
agent: 'codex',
setThreadGoalObjective
})
).toEqual({ handled: true, accepted: false, error: null })
})
it('reads the objective a goal-mode draft names, with or without a typed /goal', () => {
expect(structuredAgentSessionGoalObjective(' Ship the parser ')).toBe('Ship the parser')
expect(structuredAgentSessionGoalObjective('/goal Ship the parser ')).toBe('Ship the parser')
expect(structuredAgentSessionGoalObjective('/GOAL')).toBe('')
// Another command is prose here: goal mode sets objectives, not commands.
expect(structuredAgentSessionGoalObjective('/model gpt-5')).toBe('/model gpt-5')
})
it('asks for an objective when a goal-capable host gets a bare /goal', async () => {
const setThreadGoalObjective = vi.fn(async () => true)
expect(
await dispatchStructuredAgentSessionComposerCommand('/goal', {
...controller,
agent: 'codex',
setThreadGoalObjective
})
).toEqual({ handled: true, accepted: false, error: 'Describe the goal after /goal.' })
expect(setThreadGoalObjective).not.toHaveBeenCalled()
})
it('keeps refusing a Codex command the model cannot carry out', async () => {
expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true)
expect(
@@ -37,6 +37,9 @@ export type StructuredAgentSessionComposerOptions = {
runConversationCommand?: (
command: AgentSessionConversationCommand
) => Promise<{ accepted: boolean; error: string | null }>
/** Present only where the host can set this session's goal; otherwise `/goal`
* stays message text the agent acts on itself. */
setThreadGoalObjective?: (objective: string) => Promise<boolean>
}
export type StructuredAgentSessionCommandOutcome = {
@@ -102,6 +105,24 @@ export function isStructuredAgentSessionComposerCommand(
)
}
/** `/goal …`, which the host answers only where it can set this session's goal. */
export function isStructuredAgentSessionGoalCommand(text: string): boolean {
return commandParts(text)?.name === 'goal'
}
/** `/goal` with nothing after it: an entrance to goal mode, not an objective. */
export function isBareStructuredAgentSessionGoalCommand(text: string): boolean {
const command = commandParts(text)
return command?.name === 'goal' && command.argument === ''
}
/** The objective a goal-mode draft names. A `/goal …` typed there out of habit
* names the same objective it would outside goal mode, never the literal command. */
export function structuredAgentSessionGoalObjective(text: string): string {
const command = commandParts(text)
return command?.name === 'goal' ? command.argument : text.trim()
}
function unavailable(name: string): StructuredAgentSessionCommandOutcome {
return {
handled: true,
@@ -115,6 +136,17 @@ export async function dispatchStructuredAgentSessionComposerCommand(
controller: StructuredAgentSessionComposerOptions
): Promise<StructuredAgentSessionCommandOutcome> {
const command = commandParts(text)
if (command?.name === 'goal' && controller.setThreadGoalObjective) {
if (!command.argument) {
return { handled: true, accepted: false, error: 'Describe the goal after /goal.' }
}
// A refusal reaches the user through the session's own error surface.
return {
handled: true,
accepted: await controller.setThreadGoalObjective(command.argument),
error: null
}
}
if (!command || !isStructuredAgentSessionComposerCommand(text, controller.agent)) {
return { handled: false, accepted: false, error: null }
}
@@ -71,6 +71,26 @@ function streamItems(
)
}
function streamRevision(
state: StructuredAgentSessionState,
row: AgentJournalRenderItem,
cursorSequence: number
): StructuredAgentSessionState {
return reduceStructuredAgentSession(state, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: cursorSequence },
items: [row],
removedItemIds: [],
submissions: []
}
}
})
}
describe('structured agent session item retention', () => {
it('bounds retained items on a long live session', () => {
const streamed = streamItems(
@@ -172,6 +192,74 @@ describe('structured agent session item retention', () => {
expect(merged.items[0]?.sequence).toBe((anchor?.sequence ?? 0) - 200)
})
it('keeps the anchor on the oldest loaded row when a live batch revises a row older than the window', () => {
// Tail snapshot of a long session: rows 200..239 loaded, 199 rows older on the host.
const hydrated = hydrate(
Array.from({ length: 40 }, (_, index) => item(index + 200)),
true
)
expect(oldestStructuredAgentSessionCursor(hydrated)?.sequence).toBe(200)
// The host revises row 50 in place; the revision keeps its original sequence.
const revised = reduceStructuredAgentSession(hydrated, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: 240 },
items: [{ ...item(50), revision: 2 }, item(240)],
removedItemIds: [],
submissions: []
}
}
})
// Anchoring on row 50 would page `before: 50` and never load rows 51..199.
expect(oldestStructuredAgentSessionCursor(revised)?.sequence).toBe(200)
expect(revised.items.some((entry) => entry.sequence === 50)).toBe(false)
expect(revised.items.at(-1)?.sequence).toBe(240)
expect(revised.cursor?.sequence).toBe(240)
expect(revised.hasOlder).toBe(true)
// The page reader serves the row at its current revision once the window reaches it.
const older = reduceStructuredAgentSession(revised, {
type: 'older-page',
requestedCursor: { epoch: 'epoch-a', sequence: 200 },
page: page(
[{ ...item(50), revision: 2 }, ...Array.from({ length: 149 }, (_, i) => item(i + 51))],
false
)
})
expect(older.items.find((entry) => entry.sequence === 50)?.revision).toBe(2)
expect(oldestStructuredAgentSessionCursor(older)?.sequence).toBe(50)
expect(older.hasOlder).toBe(false)
})
it('applies a live revision of a row the window holds, including its oldest row', () => {
const hydrated = hydrate(
Array.from({ length: 3 }, (_, index) => item(index + 200)),
true
)
const revised = streamRevision(hydrated, { ...item(200), revision: 2 }, 203)
expect(revised.items.find((entry) => entry.sequence === 200)?.revision).toBe(2)
expect(oldestStructuredAgentSessionCursor(revised)?.sequence).toBe(200)
})
it('admits a live row below the head when nothing older is left on the host', () => {
// Row 1 was tombstoned before this client attached, so the window starts at 2 and
// covers the whole journal; a revival of row 1 leaves no hole to skip.
const hydrated = hydrate(
Array.from({ length: 3 }, (_, index) => item(index + 2)),
false
)
const revived = streamRevision(hydrated, { ...item(1), revision: 2 }, 5)
expect(oldestStructuredAgentSessionCursor(revived)?.sequence).toBe(1)
expect(revived.hasOlder).toBe(false)
})
it('keeps item identity stable when a batch carries no journal change', () => {
const hydrated = hydrate([item(0)])
const unchanged = reduceStructuredAgentSession(hydrated, {
@@ -3,7 +3,12 @@ import {
normalizeOptionalField,
normalizePromptField
} from './agent-status-field-normalization'
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import {
AGENT_JOURNAL_MESSAGE_SEND_MODES,
type AgentJournalMessageSendMode,
type AgentJournalRenderItem,
type AgentJournalSubmission
} from './agent-session-journal-types'
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import {
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
@@ -123,6 +128,10 @@ function itemBlocks(item: AgentJournalRenderItem): {
}
}
function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode {
return AGENT_JOURNAL_MESSAGE_SEND_MODES.some((mode) => mode === value)
}
const projectedItems = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
/** Deliberately NOT scoped by producer: the transcript shows every agent's
@@ -151,13 +160,16 @@ export function projectStructuredItemToNativeChat(
}
// Reducer updates replace journal items, so unchanged rows keep their render caches.
const projected = itemBlocks(item)
const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined
const message: NativeChatMessage | null = projected
? {
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
source: 'transcript',
// A send mode this build cannot name renders as an ordinary message.
...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {})
}
: null
projectedItems.set(item, message)
+23 -2
View File
@@ -119,6 +119,26 @@ function mergeItems(
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
}
/**
* Live rows the loaded window can take. The window is a contiguous suffix of the
* journal, and its oldest row is the load-older anchor. A revision of a row older
* than the window keeps that row's original sequence, so admitting it would move
* the anchor below the window and paging `before` it would skip every row between.
* The journal keeps the revision; the page reader serves it once the window
* reaches the row. With nothing older on the host the window is the whole journal
* and a row below the head (a revived tombstone) leaves no hole, so it is admitted.
*/
function liveItemsWithinWindow(
state: StructuredAgentSessionState,
incoming: readonly AgentJournalRenderItem[]
): readonly AgentJournalRenderItem[] {
const head = state.items[0]
if (!head || !state.hasOlder) {
return incoming
}
return incoming.filter((item) => item.sequence >= head.sequence)
}
function trimRetainedItems(
items: AgentJournalRenderItem[],
limit: number
@@ -220,8 +240,9 @@ export function reduceStructuredAgentSession(
const backgroundTasks =
event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks
const activity = event.activity !== undefined ? event.activity : state.activity
const liveItems = liveItemsWithinWindow(state, event.batch.items)
const journalUnchanged =
event.batch.items.length === 0 &&
liveItems.length === 0 &&
event.batch.removedItemIds.length === 0 &&
event.batch.submissions.length === 0
if (
@@ -240,7 +261,7 @@ export function reduceStructuredAgentSession(
}
const merged = journalUnchanged
? state.items
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds)
: mergeItems(state.items, liveItems, event.batch.removedItemIds)
const items = trimRetainedItems(merged, state.retainedItemLimit)
return {
...state,
@@ -68,6 +68,7 @@ export function structuredHostStub(
release: vi.fn(() => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
changeThreadGoal: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })),
@@ -84,6 +84,11 @@ export const STRUCTURED_CALLS: {
hostMethod: 'setOption',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.threadGoal',
hostMethod: 'changeThreadGoal',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.requestHandoff',
hostMethod: 'requestHandoff',
@@ -258,6 +263,10 @@ export function paramsFor(method: string): unknown {
const fields = { key: 'model', value: 'gpt-5' }
return { envelope: envelope({ method, fields, fence }), ...fields }
}
case 'agentSession.threadGoal': {
const fields = { change: { kind: 'set', objective: 'Ship the parser' } }
return { envelope: envelope({ method, fields, fence }), ...fields }
}
case 'agentSession.history':
return { sessionId: SESSION, direction: 'tail' }
case 'agentSession.hold':