mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
* 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.
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
import {
|
|
getHostClaimedNativeChatCommands,
|
|
getTextDrivenNativeChatCommands
|
|
} from './native-chat-agent-profiles'
|
|
import type { AgentType } from './agent-status-types'
|
|
import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options'
|
|
import type { SlashCommandSuggestion } from './native-chat-slash-commands'
|
|
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
|
|
|
|
const MODEL_COMMAND: SlashCommandSuggestion = {
|
|
name: 'model',
|
|
description: 'Choose the model'
|
|
}
|
|
|
|
const EFFORT_COMMAND: SlashCommandSuggestion = {
|
|
name: 'effort',
|
|
description: 'Choose reasoning effort'
|
|
}
|
|
|
|
const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [
|
|
{ name: 'clear', description: 'Start a fresh conversation' },
|
|
{ name: 'compact', description: 'Compact conversation context' }
|
|
]
|
|
|
|
/** Session options remain available on hosts predating conversation commands. */
|
|
export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [
|
|
MODEL_COMMAND,
|
|
EFFORT_COMMAND
|
|
]
|
|
|
|
export type StructuredAgentSessionComposerOptions = {
|
|
agent?: AgentType
|
|
snapshot: readonly SessionOptionDescriptor[]
|
|
invokeAction: (id: string) => Promise<boolean>
|
|
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
|
|
conversationCommands?: readonly AgentSessionConversationCommand[]
|
|
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 = {
|
|
handled: boolean
|
|
accepted: boolean
|
|
error: string | null
|
|
}
|
|
|
|
function commandParts(text: string): { name: string; argument: string } | null {
|
|
if (!text.startsWith('/')) {
|
|
return null
|
|
}
|
|
const match = /^\/([^\s]+)(?:\s+(.*))?$/.exec(text.trimEnd())
|
|
return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null
|
|
}
|
|
|
|
/** The commands the composer menu offers when the host reports no catalog of its
|
|
* own: the host's own commands, plus the ones this agent acts on from message
|
|
* text. Both are honored — the first here, the second by the agent — so a menu
|
|
* pick is never answered with "not available". */
|
|
export function structuredSlashCommands(
|
|
commands: readonly AgentSessionConversationCommand[] = [],
|
|
agent?: AgentType | null
|
|
): readonly SlashCommandSuggestion[] {
|
|
const hostOwned = [
|
|
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
|
|
...CONVERSATION_COMMANDS.filter((entry) =>
|
|
commands.includes(entry.name as AgentSessionConversationCommand)
|
|
)
|
|
]
|
|
// Why: a host with no catalog to report would otherwise hide the commands the
|
|
// agent itself implements, e.g. Codex's `/goal`.
|
|
return [
|
|
...hostOwned,
|
|
...getTextDrivenNativeChatCommands(agent).filter(
|
|
(entry) => !hostOwned.some((offered) => offered.name === entry.name)
|
|
)
|
|
]
|
|
}
|
|
|
|
/** Wider than the offered menu on purpose: a TUI-only command still has to be
|
|
* claimed here and answered, or a hand-typed `/clear` reaches the model as
|
|
* literal prompt text. Commands the agent itself implements are deliberately
|
|
* absent — the profile unclaims those so they pass through as text. */
|
|
function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
|
|
return [
|
|
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
|
|
...CONVERSATION_COMMANDS,
|
|
...getHostClaimedNativeChatCommands(agent)
|
|
]
|
|
}
|
|
|
|
/** Whether the chat host, rather than the agent, owns this command. Callers also
|
|
* use it to refuse attachments: a host command sends no message, so attachments
|
|
* would be silently dropped, whereas a pass-through command is a real send. */
|
|
export function isStructuredAgentSessionComposerCommand(
|
|
text: string,
|
|
agent: AgentType = 'codex'
|
|
): boolean {
|
|
const command = commandParts(text)
|
|
return Boolean(
|
|
command && structuredRecognizedCommands(agent).some((entry) => entry.name === command.name)
|
|
)
|
|
}
|
|
|
|
/** `/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,
|
|
accepted: true,
|
|
error: `/${name} is not available in chat sessions. Use the slash menu to see available commands.`
|
|
}
|
|
}
|
|
|
|
export async function dispatchStructuredAgentSessionComposerCommand(
|
|
text: string,
|
|
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 }
|
|
}
|
|
if (command.name === 'clear' || command.name === 'compact') {
|
|
if (command.argument) {
|
|
return { handled: true, accepted: false, error: `Use /${command.name} without arguments.` }
|
|
}
|
|
if (
|
|
!controller.conversationCommands?.includes(command.name) ||
|
|
!controller.runConversationCommand
|
|
) {
|
|
return {
|
|
handled: true,
|
|
accepted: false,
|
|
error: `/${command.name} is not supported by this chat host.`
|
|
}
|
|
}
|
|
return { handled: true, ...(await controller.runConversationCommand(command.name)) }
|
|
}
|
|
if (command.name !== 'model' && command.name !== 'effort') {
|
|
return unavailable(command.name)
|
|
}
|
|
const descriptor = controller.snapshot.find((entry) => entry.id === command.name)
|
|
if (!descriptor || descriptor.kind.type !== 'select') {
|
|
return {
|
|
handled: true,
|
|
accepted: true,
|
|
error: `${command.name === 'model' ? 'Models' : 'Reasoning effort'} are unavailable for this chat session.`
|
|
}
|
|
}
|
|
if (!command.argument) {
|
|
const opened = await controller.invokeAction(command.name)
|
|
return {
|
|
handled: true,
|
|
accepted: opened,
|
|
error: opened ? null : `Could not open the ${command.name} picker.`
|
|
}
|
|
}
|
|
const normalized = command.argument.toLowerCase()
|
|
const choice = descriptor.kind.choices.find(
|
|
(entry) => entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized
|
|
)
|
|
if (!choice) {
|
|
return {
|
|
handled: true,
|
|
accepted: false,
|
|
error: `${command.argument} is not an available ${command.name} for this chat session.`
|
|
}
|
|
}
|
|
const applied = await controller.setOption(command.name, choice.value)
|
|
return {
|
|
handled: true,
|
|
accepted: applied,
|
|
error: applied ? null : `Could not apply ${command.name} ${choice.label}.`
|
|
}
|
|
}
|