From 563dd5487fb6ca2f9a79b12075df2ed054934d7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:34:06 -0700 Subject: [PATCH] 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 ` typed inside goal mode sets the objective ``, 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. --- src/main/codex/codex-goal-journal-rows.ts | 59 ++++ ...dex-structured-journal-goal-resume.test.ts | 25 +- ...x-structured-journal-goal-revision.test.ts | 188 ++++++++++ ...codex-structured-journal-goal-rows.test.ts | 28 ++ .../codex/codex-structured-journal-goals.ts | 88 ++++- .../codex/codex-structured-session-adapter.ts | 11 + .../codex-structured-thread-goal.test.ts | 94 +++++ .../codex/codex-structured-thread-goal.ts | 52 +++ .../agent-session-journal/journal-store.ts | 6 + ...structured-agent-session-adapter-router.ts | 11 + .../structured-agent-session-adapter.ts | 13 + ...structured-agent-session-host-mutations.ts | 22 +- .../structured-agent-session-host.ts | 1 + ...ructured-agent-session-thread-goal.test.ts | 330 ++++++++++++++++++ .../structured-agent-session-thread-goal.ts | 123 +++++++ .../unhandled-provider-frame.ts | 19 +- ...ession-gate-classification.test-fixture.ts | 4 + ...ructured-agent-session-rpc.test-fixture.ts | 1 + .../structured-agent-session-schemas.ts | 1 + .../structured-agent-session-thread-goal.ts | 19 + .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 4 +- .../native-chat/NativeChatComposer.tsx | 41 +-- .../native-chat/NativeChatComposerActions.tsx | 7 +- .../native-chat/NativeChatComposerField.tsx | 16 +- .../NativeChatComposerGoalChip.tsx | 37 ++ .../native-chat/NativeChatMessageList.tsx | 10 +- .../native-chat/NativeChatMessageRow.test.tsx | 30 ++ .../native-chat/NativeChatMessageRow.tsx | 7 + .../NativeChatStructuredSession.tsx | 44 ++- .../NativeChatThreadGoalBanner.test.tsx | 130 +++++++ .../NativeChatThreadGoalBanner.tsx | 117 +++++++ .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-thread-goal-presentation.ts | 32 ++ .../native-chat-thread-goal-rows.test.ts | 45 +++ .../native-chat-thread-goal-rows.ts | 16 + .../use-native-chat-composer-submit.test.tsx | 207 +++++++++++ .../use-native-chat-composer-submit.ts | 146 ++++++++ ...ive-chat-structured-composer-send.test.tsx | 19 +- ...se-native-chat-structured-composer-send.ts | 12 +- .../use-structured-agent-session-options.ts | 12 +- ...uctured-agent-session-thread-goal.test.tsx | 128 +++++++ ...se-structured-agent-session-thread-goal.ts | 56 +++ .../use-structured-agent-session.ts | 37 +- src/renderer/src/i18n/locales/en.json | 18 +- .../agent-session-journal-schemas.test.ts | 79 +++++ src/shared/agent-session-journal-schemas.ts | 26 +- src/shared/agent-session-journal-types.ts | 40 +++ src/shared/agent-session-thread-goal.test.ts | 140 ++++++++ src/shared/agent-session-thread-goal.ts | 126 +++++++ src/shared/agent-session-wire.ts | 20 ++ src/shared/native-chat-types.ts | 3 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 22 +- .../structured-agent-session-composer.test.ts | 43 +++ .../structured-agent-session-composer.ts | 32 ++ ...tured-agent-session-item-retention.test.ts | 88 +++++ .../structured-agent-session-projection.ts | 16 +- .../structured-agent-session-reducer.ts | 25 +- .../structured-agent-session-host-fixture.ts | 1 + ...ructured-agent-session-surface-manifest.ts | 9 + 61 files changed, 2843 insertions(+), 99 deletions(-) create mode 100644 src/main/codex/codex-structured-journal-goal-revision.test.ts create mode 100644 src/main/codex/codex-structured-thread-goal.test.ts create mode 100644 src/main/codex/codex-structured-thread-goal.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerGoalChip.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-thread-goal-rows.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts create mode 100644 src/shared/agent-session-thread-goal.test.ts create mode 100644 src/shared/agent-session-thread-goal.ts diff --git a/src/main/codex/codex-goal-journal-rows.ts b/src/main/codex/codex-goal-journal-rows.ts index 36dac339901..2190b71115c 100644 --- a/src/main/codex/codex-goal-journal-rows.ts +++ b/src/main/codex/codex-goal-journal-rows.ts @@ -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 | 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 +} diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4cf87b790bb..bb6c4b53615 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -43,6 +43,7 @@ function goalJournal( const rows = new Map() 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() }) }) diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts new file mode 100644 index 00000000000..9c4f0cd557a --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -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 = {}) { + 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() + }) +}) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts index 4c3837b4373..7af1d863e17 100644 --- a/src/main/codex/codex-structured-journal-goal-rows.test.ts +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -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() diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 83bb66551ae..6d9f1917881 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -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() - 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 diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 993678b82f2..b9affd8bd00 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -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 = (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 }) diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts new file mode 100644 index 00000000000..97f4cb9fb73 --- /dev/null +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -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) { + // 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' + ) + }) +}) diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts new file mode 100644 index 00000000000..59da154e09f --- /dev/null +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -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 +} + +/** 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, + 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 + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index e78b6d0ce8e..fb651518f15 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 1cc571d39aa..01261168025 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -86,6 +86,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => this.owner(input.sessionId).cancelTurn(input) + changeThreadGoal: NonNullable = (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 = ( input ) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index ac73526fd1c..94d03ddd32b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 2c8bd5fbabc..0608549a8e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -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> { + 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[2] ) => setStructuredAgentSessionOption(context(), caller, params), + changeThreadGoal: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => changeStructuredAgentSessionThreadGoal(context(), caller, params), readOptions: (sessionId: string) => readStructuredAgentSessionOptions(context(), sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 2a1ebb2ac49..b439f880c90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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 = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts new file mode 100644 index 00000000000..3d4e8491986 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -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 { + 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 +): Promise { + 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, + flushStreamedEvents: () => Promise = 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) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts new file mode 100644 index 00000000000..4e49e00f587 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -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 { + 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> { + 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 => { + if (change.kind === 'set') { + // Nothing was sent as a goal. + await ctx.journal.appendTombstone(identity, { fence: ctx.fence }) + ctx.publish() + } + } + let result: Awaited> + 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 { + 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 + } +} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index d1efdcbb3f6..cbb0857c859 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -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' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 9570fe0abb0..9ac89a48833 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -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' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index aad992d4926..d04b92b8ed2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 6ae8d8d04a4..d920899a836 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -24,5 +24,6 @@ export { SessionId, SetOptionParams, SubscribeParams, + ThreadGoalParams, UnsubscribeParams } from '../../../../shared/rpc-contract/structured-agent-session-params' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts new file mode 100644 index 00000000000..b28a51500d2 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts @@ -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) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 133e2412458..6eabd3c0540 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -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 () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 287bbc8228d..d93fa9c86e6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -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 ] diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 60a8c8210d0..409ec311940 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -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 { - 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[0]) => { - if (structuredTransport) { - sendStructured(`/${command.name}`) - return - } - dispatchPtyPickerCommand(command) - }, + (command: Parameters[0]) => + structuredTransport + ? sendStructured(`/${command.name}`) + : dispatchPtyPickerCommand(command), [dispatchPtyPickerCommand, sendStructured, structuredTransport] ) @@ -351,8 +343,8 @@ const NativeChatComposerPane = forwardRef { if (autocomplete.mode !== 'mention') { diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 3ad7f6ab2be..a5734070099 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -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): 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')} + {onExitGoalMode ? : null}
{/* Why: keep session controls beside the actions they affect; the diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index a4b104efd0f..b1b78a0cc0c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -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} />
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerGoalChip.tsx b/src/renderer/src/components/native-chat/NativeChatComposerGoalChip.tsx new file mode 100644 index 00000000000..d64cd1e0acb --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerGoalChip.tsx @@ -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 ( + + + + + + {exitLabel} + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6004cc33f55..e438ebae8c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -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 diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 6450face0ba..3e2985e34bc 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -101,3 +101,33 @@ describe('MessageRow control visibility', () => { expect(screen.queryByRole('button')).toBeNull() }) }) + +describe('MessageRow send mode', () => { + function renderUser(sentAs?: NativeChatMessage['sentAs']) { + return render( + + ) + } + + 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() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 52b4e895b7b..3388fa79082 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -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({ /> )} + {message.sentAs === 'goal' ? ( +
+ + {translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')} +
+ ) : null} ({ + 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 (
+ {!prompt && controller.threadGoal?.goal ? ( + void controller.threadGoal?.change(change)} + /> + ) : null} {prompt ? null : ( { + cleanup() + vi.useRealTimers() +}) + +const NOW = 10_000_000 + +function goal(overrides: Partial = {}): 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( + + + + ) + 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) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx new file mode 100644 index 00000000000..20cf93af809 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx @@ -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 ( + + + + + + {props.label} + + + ) +} + +/** 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. +
+
+
+ +

+ {label}{' '} + {goal.objective} + {` • ${elapsed}`} +

+
+ onChange({ kind: 'clear' })} + > + + + {statusChange === 'paused' ? ( + onChange({ kind: 'status', status: 'paused' })} + > + + + ) : statusChange === 'active' ? ( + onChange({ kind: 'status', status: 'active' })} + > + + + ) : null} + setExpanded(!expanded)}> + {expanded ? : } + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 3565d01a03d..9c68c2dbbf8 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -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 } onError: (message: string | null) => void runtime: 'local' | 'remote' /** The session behind this composer; a real user send relinquishes orchestration ownership. */ diff --git a/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts b/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts new file mode 100644 index 00000000000..9e52e452329 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-thread-goal-presentation.ts @@ -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` +} diff --git a/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts b/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts new file mode 100644 index 00000000000..2b0b5836a3f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts @@ -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]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.ts b/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.ts new file mode 100644 index 00000000000..347f6ea56f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-thread-goal-rows.ts @@ -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) + ) + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx new file mode 100644 index 00000000000..bbbf99d2a5d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -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((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) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts new file mode 100644 index 00000000000..7bf8ea7f870 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -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: ( + 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( + (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 } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 82cd0f0cb03..39892a49219 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -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() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index a68d14fce75..f208e6cb4a9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -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) ) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 34dad47e495..8116fd90673 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -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 diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx new file mode 100644 index 00000000000..2de4a2564e4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx @@ -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 + +/** 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 = Promise.resolve(false) + let second: Promise = 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) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts new file mode 100644 index 00000000000..a963a3b9c9a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -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 +} + +/** 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 => { + if (pendingRef.current) { + return false + } + pendingRef.current = true + setPending(true) + try { + const result = await mutate( + 'agentSession.threadGoal', + 'agentSession.threadGoal', + { change: next } + ) + return result !== null + } finally { + pendingRef.current = false + setPending(false) + } + }, + [mutate] + ) + return support ? { goal, pending, change } : null +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index cfc07d3e4d5..c093eb70837 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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 } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dc38a86451f..e013a1cfce6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 8f07cca800a..ec66caaa059 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -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) + } + }) +}) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 43f96242785..52f3bfe9b88 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -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'), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 51ff5d1dc8d..8a49117eeea 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -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 diff --git a/src/shared/agent-session-thread-goal.test.ts b/src/shared/agent-session-thread-goal.test.ts new file mode 100644 index 00000000000..3a868ddb6d7 --- /dev/null +++ b/src/shared/agent-session-thread-goal.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/src/shared/agent-session-thread-goal.ts b/src/shared/agent-session-thread-goal.ts new file mode 100644 index 00000000000..fc0b3ffbc9a --- /dev/null +++ b/src/shared/agent-session-thread-goal.ts @@ -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 + +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 +): 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)) +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 7e03200191e..0bd97464af4 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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 diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 1eee5e0e2a3..24bf8652fb2 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -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 diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index e27aaa43ff1..4e983006dac 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 8afca124482..bcdc2a98630 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -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. */ diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index b51de0396be..aaaad68e305 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -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( diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 093d588effb..9b60920ac6b 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -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 } 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 { 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 } } diff --git a/src/shared/structured-agent-session-item-retention.test.ts b/src/shared/structured-agent-session-item-retention.test.ts index f9763a02cba..c29f857e212 100644 --- a/src/shared/structured-agent-session-item-retention.test.ts +++ b/src/shared/structured-agent-session-item-retention.test.ts @@ -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, { diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 0a4c1474048..5f45d001c18 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -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() /** 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) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index b86e927c15d..df7f87468d0 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -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, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 465899751a2..8be8df7aa02 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -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' } })), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 97588ae0cfb..1565a67ed5a 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -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':