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

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

Fixes STA-8159

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

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

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

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

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

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

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

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

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

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

253 lines
9.4 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
dispatchStructuredAgentSessionComposerCommand,
isStructuredAgentSessionComposerCommand,
structuredAgentSessionGoalObjective,
structuredSlashCommands
} from './structured-agent-session-composer'
describe('structuredSlashCommands', () => {
const hostController = {
snapshot: [],
invokeAction: async () => true,
setOption: async () => true,
conversationCommands: ['clear', 'compact'] as const,
runConversationCommand: async () => ({ accepted: true, error: null })
}
const REFUSAL = /is not available in chat sessions/
// The composer menu and the dispatcher read the same policy. When they disagreed,
// a Claude session was offered Codex-only tokens that missed the command guard
// and reached the model as literal prompt text instead of erroring. A row is
// honored either way now: the host answers it, or it passes through to the agent.
it.each(['codex', 'claude'] as const)(
'offers %s only commands the host answers or the agent runs',
async (agent) => {
const offered = structuredSlashCommands(['clear', 'compact'], agent)
expect(offered.length).toBeGreaterThan(0)
for (const command of offered) {
const outcome = await dispatchStructuredAgentSessionComposerCommand(`/${command.name}`, {
...hostController,
agent
})
expect(outcome.error ?? '').not.toMatch(REFUSAL)
}
}
)
// Codex reports no catalog of its own, so this fallback is its whole `/` menu —
// without the row, a command that now works is impossible to discover.
it('offers Codex the /goal the model acts on, described from the catalog', () => {
const offered = structuredSlashCommands(['clear', 'compact'], 'codex')
expect(offered.map((command) => command.name)).toEqual([
'model',
'effort',
'clear',
'compact',
'goal'
])
expect(offered.find((command) => command.name === 'goal')?.description).toBe(
'Set or view the goal'
)
// Picking it must reach the model, not the host's refusal.
expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false)
})
it('adds nothing for an agent whose own harness expands its commands', () => {
expect(structuredSlashCommands(['clear', 'compact'], 'claude').map((c) => c.name)).toEqual([
'model',
'effort',
'clear',
'compact'
])
})
it('offers only the commands a chat session can carry out', () => {
expect(structuredSlashCommands().map((command) => command.name)).toEqual(['model', 'effort'])
})
it('adds only implemented host-supported conversation commands', () => {
expect(structuredSlashCommands(['clear', 'compact']).map((command) => command.name)).toEqual([
'model',
'effort',
'clear',
'compact'
])
expect(structuredSlashCommands(['compact']).map((command) => command.name)).toEqual([
'model',
'effort',
'compact'
])
})
})
describe('isStructuredAgentSessionComposerCommand', () => {
// The menu hides TUI-only commands, but the guard must still claim a typed one
// so it is answered here instead of sent to the model as prose.
it.each([
['codex', 'vim'],
['codex', 'clear'],
['claude', 'compact'],
['claude', 'clear']
] as const)('claims the unoffered %s command /%s', (agent, name) => {
expect(isStructuredAgentSessionComposerCommand(`/${name}`, agent)).toBe(true)
})
it('leaves an unknown token to the chat path', () => {
expect(isStructuredAgentSessionComposerCommand('/my-skill', 'claude')).toBe(false)
})
})
describe('dispatchStructuredAgentSessionComposerCommand', () => {
const controller = {
agent: 'codex' as const,
snapshot: [],
invokeAction: async () => true,
setOption: async () => true
}
it('names what does work when a TUI-only command is typed', async () => {
const outcome = await dispatchStructuredAgentSessionComposerCommand('/vim', controller)
expect(outcome.handled).toBe(true)
expect(outcome.error).toBe(
'/vim is not available in chat sessions. Use the slash menu to see available commands.'
)
})
it.each(['claude', 'codex'] as const)(
'handles %s conversation commands without message fallthrough',
async (agent) => {
const runConversationCommand = vi.fn(async () => ({ accepted: true, error: null }))
for (const command of ['clear', 'compact'] as const) {
const result = await dispatchStructuredAgentSessionComposerCommand(`/${command}`, {
...controller,
agent,
conversationCommands: ['clear', 'compact'],
runConversationCommand
})
expect(result).toEqual({ handled: true, accepted: true, error: null })
expect(runConversationCommand).toHaveBeenLastCalledWith(command)
}
}
)
it('retains a draft on unsupported hosts and rejects arguments before dispatch', async () => {
expect(await dispatchStructuredAgentSessionComposerCommand('/clear', controller)).toMatchObject(
{ handled: true, accepted: false, error: '/clear is not supported by this chat host.' }
)
const runConversationCommand = vi.fn()
expect(
await dispatchStructuredAgentSessionComposerCommand('/compact keep this', {
...controller,
conversationCommands: ['compact'],
runConversationCommand
})
).toMatchObject({ handled: true, accepted: false })
expect(runConversationCommand).not.toHaveBeenCalled()
})
})
describe('agent-implemented commands pass through to the agent', () => {
const controller = {
snapshot: [],
invokeAction: async () => true,
setOption: async () => true
}
const PASSED_THROUGH = { handled: false, accepted: false, error: null }
// Claude's harness runs a slash command it finds in the message text, so
// claiming these answered "not available" for commands that do work.
it.each(['init', 'review', 'help'] as const)(
'sends /%s on to the Claude harness instead of refusing it',
async (name) => {
expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(false)
expect(
await dispatchStructuredAgentSessionComposerCommand(`/${name}`, {
...controller,
agent: 'claude'
})
).toEqual(PASSED_THROUGH)
}
)
it.each(['clear', 'compact', 'model', 'effort'] as const)(
'still claims the host-owned /%s on Claude',
async (name) => {
expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(true)
expect(
(
await dispatchStructuredAgentSessionComposerCommand(`/${name}`, {
...controller,
agent: 'claude'
})
).handled
).toBe(true)
}
)
// Codex's app-server has no slash parser, but the model owns goal tools and
// creates a real goal from `/goal <objective>` arriving as prose.
it('passes /goal through on Codex, arguments and all', async () => {
expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false)
expect(
await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', {
...controller,
agent: 'codex'
})
).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(
await dispatchStructuredAgentSessionComposerCommand('/permissions', {
...controller,
agent: 'codex'
})
).toMatchObject({
handled: true,
error:
'/permissions is not available in chat sessions. Use the slash menu to see available commands.'
})
})
})