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