Track agent prompt sent from agent hooks (#3033)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-29 12:45:34 -07:00 committed by GitHub
parent 10eb4d05bf
commit 423a6d11e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1454 additions and 60 deletions

View File

@ -39,13 +39,14 @@ D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/7
- `source = 'unknown'` is not a real product surface. It means the caller omitted a source or the value failed schema validation.
- `workspace_created` means create-worktree IPC succeeded. It is not a general "usable workspace exists" or "workspace revealed" marker.
- `agent_started` means PTY spawn succeeded with agent telemetry attached. It is not first-repo activation and does not prove the user sent a prompt.
- `agent_prompt_sent` means a live agent hook observed an explicit non-empty user prompt. It excludes hydrated/replayed status, agent auto-start, bare shells, draft prefill, and hookless sessions; missing rows mean no hook-confirmed interaction was observed, not proof the user never typed.
- Workspace-outcome joins are native Electron coverage unless the query explicitly proves remote/web instrumentation. Remote runtime and web paths can bypass native repo/worktree telemetry, so do not interpret missing workspace outcome rows as product drop-off for SSH, remote, or web users.
## Rollouts
### 2026-05-08 - Repo Cohort Property
Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below.
Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below.
| Field | Value |
| --- | --- |
@ -65,6 +66,7 @@ PostHog evidence checked at `2026-05-23T23:34:32Z`:
| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) |
| `workspace_created` | `2026-05-08T19:15:06.913Z` |
| `agent_started` | `2026-05-08T19:15:07.130Z` |
| `agent_prompt_sent` | `TBD` |
| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) |
| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` |
| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` |

View File

@ -20,7 +20,8 @@ import {
} from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
const { trackMock } = vi.hoisted(() => ({
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
@ -28,6 +29,10 @@ vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
@ -62,6 +67,8 @@ function buildBody(payload: Record<string, unknown>, overrides: Partial<Body> =
beforeEach(() => {
_internals.resetCachesForTests()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
@ -2171,6 +2178,537 @@ describe('AgentHookServer listener replay', () => {
})
})
describe('AgentHookServer prompt-sent telemetry', () => {
it('tracks a live local hook explicit prompt with conservative attribution', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'UserPromptSubmit',
prompt: ' fix the spinner '
})
)
})
expect(response.status).toBe(204)
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'claude-code',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 2
})
} finally {
server.stop()
}
})
it('tracks a live SSH hook explicit prompt through ingestRemote', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'remote prompt', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'codex',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 2
})
})
it('dedupes adjacent same-turn reports without considering hook state', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'same turn', agentType: 'gemini' }
},
'conn-1'
)
vi.setSystemTime(1_500)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'done', prompt: 'same turn', agentType: 'gemini' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'gemini',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 2
})
} finally {
vi.useRealTimers()
}
})
it('tracks the same prompt again after a completed turn starts over', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'continue', agentType: 'codex' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'continue', agentType: 'codex' }
},
'conn-1'
)
vi.setSystemTime(1_500)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'continue', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('dedupes duplicate Command Code stop hooks but tracks same-prompt reruns', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-user-1',
payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-user-1',
payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-user-2',
payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(2)
})
it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'same command', agentType: 'command-code' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-a-1',
payload: { state: 'done', prompt: 'same command', agentType: 'command-code' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(1)
})
it('does not let a reused interaction key suppress different prompt text', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-reused',
payload: { state: 'done', prompt: 'first command', agentType: 'command-code' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-reused',
payload: { state: 'done', prompt: 'second command', agentType: 'command-code' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(2)
})
it('does not treat Command Code cached prompts as explicit prompt evidence', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: false,
payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(1)
})
it('preserves prompt dedupe when a live status row is dismissed', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'long turn', agentType: 'codex' }
},
'conn-1'
)
server.dropStatusEntry(PANE)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'long turn', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(1)
})
it('lets a dismissed completed row start the same prompt again', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'done', prompt: 'rerun after done', agentType: 'codex' }
},
'conn-1'
)
server.dropStatusEntry(PANE)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'rerun after done', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(2)
})
it('dedupes the same prompt until a completed turn boundary is observed', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' }
},
'conn-1'
)
vi.setSystemTime(32_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('does not track replays, empty prompts, or inherited prompt snapshots', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
isReplay: true,
payload: { state: 'working', prompt: 'replayed prompt', agentType: 'codex' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: GOOD_PANE,
tabId: 'tab-good',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: ' ', agentType: 'codex' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: FRESH_PANE,
tabId: 'tab-fresh',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'inherited prompt', agentType: 'codex' }
},
'conn-1'
)
expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track hook status messages that preserve a cached prompt', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'real prompt', agentType: 'droid' }
},
'conn-1'
)
trackMock.mockClear()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: false,
payload: { state: 'waiting', prompt: 'real prompt', agentType: 'droid' }
},
'conn-1'
)
expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('tracks OpenCode user MessagePart hooks once per message id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'MessagePart',
role: 'user',
text: 'fix',
messageID: 'msg-1'
})
)
})
const updatedResponse = await fetch(
`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'MessagePart',
role: 'user',
text: 'fix tests',
messageID: 'msg-1'
})
)
}
)
expect(response.status).toBe(204)
expect(updatedResponse.status).toBe(204)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'fix tests',
agentType: 'opencode'
})
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'opencode',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 2
})
} finally {
server.stop()
}
})
it('maps custom hook agent types to other', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'custom prompt', agentType: 'my-local-agent' }
},
'conn-1'
)
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'other',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 2
})
})
it('does not block status cache mutation or listener fanout when telemetry throws', () => {
const server = new AgentHookServer()
const listener = vi.fn()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
trackMock.mockImplementationOnce(() => {
throw new Error('telemetry unavailable')
})
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'keep status moving', agentType: 'codex' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
prompt: 'keep status moving',
agentType: 'codex'
})
])
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
payload: expect.objectContaining({ prompt: 'keep status moving' })
})
)
errorSpy.mockRestore()
})
})
describe('Claude hook normalization', () => {
it('PostToolUse for Edit surfaces toolName + file_path preview', () => {
const result = _internals.normalizeHookPayload(
@ -2808,11 +3346,18 @@ describe('OpenCode hook normalization', () => {
it('MessagePart with role=user surfaces text as the prompt and stays working', () => {
const result = _internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi there' }),
buildBody({
hook_event_name: 'MessagePart',
role: 'user',
text: 'hi there',
messageID: 'msg-1'
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('hi there')
expect(result?.hasExplicitPrompt).toBe(true)
expect(result?.promptInteractionKey).toBe('opencode-message-msg-1')
})
it('MessagePart with role=assistant populates lastAssistantMessage', () => {
@ -3108,6 +3653,7 @@ describe('Droid hook normalization', () => {
expect(done?.payload.state).toBe('done')
expect(done?.payload.prompt).toBe('write tests')
expect(done?.hasExplicitPrompt).toBe(false)
})
it('Notification ignores confirmation status text rather than treating it as permission', () => {
@ -3598,6 +4144,7 @@ describe('Copilot hook normalization', () => {
expect(result?.payload.state).toBe('blocked')
expect(result?.payload.prompt).toBe('deploy the app')
expect(result?.payload.lastAssistantMessage).toBe('Which environment?')
expect(result?.hasExplicitPrompt).toBe(false)
})
it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => {
@ -4215,6 +4762,32 @@ describe('Last-status persistence', () => {
}
})
it('does not write prompt interaction keys to last-status.json', async () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath
})
try {
await postHookEvent(
server,
buildBody({
hook_event_name: 'MessagePart',
role: 'user',
text: 'persist status only',
messageID: 'opencode-local-message-id'
}),
'/hook/opencode'
)
server.flushStatusPersistSync()
const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(file.entries[PANE].payload.prompt).toBe('persist status only')
expect(file.entries[PANE].promptInteractionKey).toBeUndefined()
} finally {
server.stop()
}
})
it('hydrates last-status.json into the cache before listener registration', async () => {
// Pre-populate the file directly to simulate a prior session.
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })

View File

@ -11,11 +11,13 @@
// - the on-disk last-status cache (`last-status.json`) that survives
// Orca restart so retained dashboard rows reappear on relaunch
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import { randomUUID } from 'crypto'
import { createHash, randomBytes, randomUUID } from 'crypto'
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
import {
clearAllListenerCaches,
@ -99,6 +101,7 @@ const LAST_STATUS_FILE_VERSION = 2
// hook-server batching; quit-time uses flushStatusPersistSync() for the
// guaranteed final flush.
const STATUS_PERSIST_DEBOUNCE_MS = 250
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// Why: bound the on-disk file's growth across many sessions. PTY-teardown
// eviction handles closed panes, but daemon-restored PTYs that never re-attach
@ -113,6 +116,25 @@ type LastStatusFile = {
entries: Record<string, EnrichedAgentHookEventPayload>
}
type AgentPromptSentDedupeEntry = {
agentKind: AgentKind
promptHash: string
promptInteractionKey?: string
}
function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind {
const normalized = agentType?.trim().toLowerCase()
if (!normalized || normalized === 'unknown') {
return 'other'
}
if (normalized === 'claude') {
return 'claude-code'
}
return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind)
? (normalized as AgentKind)
: 'other'
}
function equivalentInterruptAgentType(
actual: AgentType | undefined,
baseline: AgentType | undefined
@ -383,6 +405,8 @@ export class AgentHookServer {
// server instances in the same process (tests) don't share state.
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
private promptSentHashSalt = randomBytes(16).toString('hex')
// Why: identity check — skip writes when the JSON-stringified contents
// exactly match the last successful disk write. Cheap protection against
// re-firing trailing timers when nothing changed.
@ -529,6 +553,76 @@ export class AgentHookServer {
}
}
private hashPromptForTelemetryDedupe(prompt: string): string {
return createHash('sha256')
.update(this.promptSentHashSalt)
.update('\0')
.update(prompt)
.digest('hex')
}
private maybeTrackAgentPromptSent(
payload: AgentHookEventPayload,
previousStatus: EnrichedAgentHookEventPayload | undefined
): void {
if (payload.isReplay === true || payload.hasExplicitPrompt !== true) {
return
}
const prompt = payload.payload.prompt?.trim() ?? ''
if (prompt.length === 0) {
return
}
const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType)
const promptHash = this.hashPromptForTelemetryDedupe(prompt)
const promptInteractionKey =
typeof payload.promptInteractionKey === 'string' &&
payload.promptInteractionKey.trim().length > 0
? payload.promptInteractionKey.trim()
: undefined
const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey)
const isCompletedTurnBoundary =
previousStatus?.payload.state === 'done' && payload.payload.state === 'working'
if (
previousDedupe?.agentKind === agentKind &&
previousDedupe.promptInteractionKey !== undefined &&
previousDedupe.promptInteractionKey === promptInteractionKey &&
(agentKind === 'opencode' || previousDedupe.promptHash === promptHash)
) {
return
}
if (
previousDedupe?.agentKind === agentKind &&
previousDedupe.promptHash === promptHash &&
!(
previousStatus?.payload.state === 'done' &&
payload.payload.state === 'done' &&
previousDedupe.promptInteractionKey !== undefined &&
promptInteractionKey !== undefined &&
previousDedupe.promptInteractionKey !== promptInteractionKey
) &&
!isCompletedTurnBoundary
) {
return
}
this.promptSentDedupeByPaneKey.set(payload.paneKey, {
agentKind,
promptHash,
promptInteractionKey
})
try {
// Why: hooks prove the user submitted a turn, but do not know which UI
// launched the terminal; keep attribution low-cardinality and conservative.
track('agent_prompt_sent', {
agent_kind: agentKind,
launch_source: 'unknown',
request_kind: 'followup',
...getCohortAtEmit()
})
} catch (err) {
console.error('[agent-hooks] prompt-sent telemetry failed', err)
}
}
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
@ -567,6 +661,7 @@ export class AgentHookServer {
) {
this.clearAssistantMessageRetry(effectivePayload.paneKey)
}
this.maybeTrackAgentPromptSent(effectivePayload, previous)
const enriched = this.attachStatusTiming(effectivePayload)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
@ -702,6 +797,7 @@ export class AgentHookServer {
if (entry.ptyId === ptyId) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
const shouldClearStablePaneKey =
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
@ -713,6 +809,7 @@ export class AgentHookServer {
// cleanup is the only path that can evict that retained status.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
}
aliasChanged = true
}
@ -763,6 +860,7 @@ export class AgentHookServer {
env?: string
version?: string
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
toolUseId?: string
toolAgentId?: string
@ -824,6 +922,11 @@ export class AgentHookServer {
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const promptInteractionKey =
typeof envelope.promptInteractionKey === 'string' &&
envelope.promptInteractionKey.trim().length > 0
? envelope.promptInteractionKey.trim()
: undefined
const toolUseId =
typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0
? envelope.toolUseId.trim()
@ -860,6 +963,7 @@ export class AgentHookServer {
worktreeId,
connectionId: trimmedConnectionId,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
promptInteractionKey,
hookEventName,
toolUseId,
toolAgentId,
@ -1001,6 +1105,7 @@ export class AgentHookServer {
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
this.promptSentDedupeByPaneKey.clear()
this.legacyPaneKeyAliases.clear()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
@ -1018,9 +1123,13 @@ export class AgentHookServer {
if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) {
return
}
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey)
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
if (existing?.payload.state === 'done') {
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@ -1034,11 +1143,13 @@ export class AgentHookServer {
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
@ -1190,7 +1301,8 @@ export class AgentHookServer {
if (!isValidPaneKey(paneKey)) {
continue
}
entries[paneKey] = payload as EnrichedAgentHookEventPayload
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
}
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
return JSON.stringify(file)
@ -1270,6 +1382,10 @@ export class AgentHookServer {
_getStateForTests(): HookListenerState {
return this.state
}
_resetPromptSentDedupeForTests(): void {
this.promptSentDedupeByPaneKey.clear()
}
}
export const agentHookServer = new AgentHookServer()
@ -1287,5 +1403,6 @@ export const _internals = {
parseFormEncodedBody,
resetCachesForTests: (): void => {
clearAllListenerCaches(agentHookServer._getStateForTests())
agentHookServer._resetPromptSentDedupeForTests()
}
}

View File

@ -210,7 +210,9 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
const pluginPath = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js')
expect(existsSync(pluginPath)).toBe(true)
// Sanity-check the file has plugin source, not a stray write.
expect(readFileSync(pluginPath, 'utf8')).toContain('OrcaOpenCodeStatusPlugin')
const pluginSource = readFileSync(pluginPath, 'utf8')
expect(pluginSource).toContain('OrcaOpenCodeStatusPlugin')
expect(pluginSource).toContain('messageID: part.messageID')
})
it('clearPty removes the same directory buildPtyEnv created', () => {

View File

@ -305,7 +305,7 @@ function getOpenCodePluginSource(): string {
' if (!part || part.type !== "text" || !part.text) return;',
' const role = messageRoleById.get(part.messageID);',
' if (!role) return;',
' await post("MessagePart", { role, text: part.text });',
' await post("MessagePart", { role, text: part.text, messageID: part.messageID });',
' return;',
' }',
'',

View File

@ -19,6 +19,19 @@ import { agentHookServer, _internals as agentHookInternals } from '../agent-hook
import { getSshPtyProvider } from '../ipc/pty'
import { toAppSshPtyId } from '../providers/ssh-pty-id'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
vi.mock('./ssh-relay-deploy', () => ({
deployAndLaunchRelay: vi.fn()
}))
@ -184,6 +197,9 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
beforeEach(() => {
vi.clearAllMocks()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 4 })
previousRemoteHooksFlag = process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = '1'
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@ -388,6 +404,97 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
)
})
it('forwards remote hook transition metadata into main ingest', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote')
session = createSession('conn-hook-metadata')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
source: 'claude',
hookEventName: 'PreToolUse',
promptInteractionKey: 'command-code-transcript-user-3',
toolUseId: 'toolu-1',
toolAgentId: 'agent-subagent-a',
toolAgentType: 'Review',
payload: {
state: 'working',
prompt: 'remote prompt',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
}
})
)
await vi.waitFor(() =>
expect(ingestSpy).toHaveBeenCalledWith(
expect.objectContaining({
hookEventName: 'PreToolUse',
promptInteractionKey: 'command-code-transcript-user-3',
toolUseId: 'toolu-1',
toolAgentId: 'agent-subagent-a',
toolAgentType: 'Review'
}),
'conn-hook-metadata'
)
)
ingestSpy.mockRestore()
})
it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
session = createSession('conn-live-telemetry')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'ssh live user prompt',
agentType: 'codex'
}
})
)
await vi.waitFor(() =>
expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'codex',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 4
})
)
trackMock.mockClear()
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
isReplay: true,
payload: {
state: 'working',
prompt: 'ssh replayed user prompt',
agentType: 'codex'
}
})
)
await new Promise((resolve) => setImmediate(resolve))
expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('preserves replay metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({

View File

@ -642,6 +642,11 @@ export class SshRelaySession {
env?: unknown
version?: unknown
hasExplicitPrompt?: unknown
promptInteractionKey?: unknown
hookEventName?: unknown
toolUseId?: unknown
toolAgentId?: unknown
toolAgentType?: unknown
isReplay?: unknown
payload?: unknown
}
@ -661,6 +666,16 @@ export class SshRelaySession {
env: typeof envelope.env === 'string' ? envelope.env : undefined,
version: typeof envelope.version === 'string' ? envelope.version : undefined,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
promptInteractionKey:
typeof envelope.promptInteractionKey === 'string'
? envelope.promptInteractionKey
: undefined,
hookEventName:
typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined,
toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined,
toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined,
toolAgentType:
typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
payload: envelope.payload
},

View File

@ -31,6 +31,16 @@ describe('validate', () => {
expect(result.ok).toBe(true)
})
it('accepts a well-formed agent_prompt_sent payload', () => {
const result = validate('agent_prompt_sent', {
agent_kind: 'claude-code',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 1
})
expect(result.ok).toBe(true)
})
it('drops unknown event names', () => {
const result = validate('not_a_real_event' as never, {})
expect(result.ok).toBe(false)

View File

@ -292,6 +292,7 @@ export class RelayAgentHookServer {
worktreeId: event.worktreeId,
connectionId: null,
hasExplicitPrompt: event.hasExplicitPrompt,
promptInteractionKey: event.promptInteractionKey,
hookEventName: event.hookEventName,
toolUseId: event.toolUseId,
toolAgentId: event.toolAgentId,

View File

@ -10,7 +10,7 @@ const testState = vi.hoisted(() => ({
unsubscribe: vi.fn(),
subscribeToPtyData: vi.fn(),
isRemoteRuntimePtyId: vi.fn(),
sendRuntimePtyInput: vi.fn(),
sendRuntimePtyInputVerified: vi.fn(),
subscribeToRuntimeTerminalData: vi.fn()
}))
@ -26,7 +26,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId,
sendRuntimePtyInput: testState.sendRuntimePtyInput
sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
@ -58,7 +58,8 @@ describe('pasteDraftWhenAgentReady', () => {
)
testState.isRemoteRuntimePtyId.mockReset()
testState.isRemoteRuntimePtyId.mockReturnValue(false)
testState.sendRuntimePtyInput.mockReset()
testState.sendRuntimePtyInputVerified.mockReset()
testState.sendRuntimePtyInputVerified.mockResolvedValue(true)
testState.subscribeToRuntimeTerminalData.mockReset()
})
@ -77,16 +78,20 @@ describe('pasteDraftWhenAgentReady', () => {
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
PASTED_ISSUE_URL
)
expect(vi.getTimerCount()).toBe(0)
})
@ -103,7 +108,11 @@ describe('pasteDraftWhenAgentReady', () => {
)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
PASTED_ISSUE_URL
)
})
it('keeps the render-quiet wait for agents without the Codex ready signal', async () => {
@ -116,15 +125,19 @@ describe('pasteDraftWhenAgentReady', () => {
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1499)
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
PASTED_ISSUE_URL
)
})
it('does not paste for agents that already use native draft prefill', async () => {
@ -137,7 +150,7 @@ describe('pasteDraftWhenAgentReady', () => {
).resolves.toBe(false)
expect(testState.subscribeToPtyData).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
})
it('can force paste and submit for native-prefill agents', async () => {
@ -154,7 +167,39 @@ describe('pasteDraftWhenAgentReady', () => {
await vi.advanceTimersByTimeAsync(1500)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', `${PASTED_ISSUE_URL}\r`)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
`${PASTED_ISSUE_URL}\r`
)
})
it('reports false when verified input delivery fails', async () => {
testState.sendRuntimePtyInputVerified.mockResolvedValue(false)
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex'
})
await flushMicrotasks()
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
await expect(promise).resolves.toBe(false)
})
it('reports false when verified input delivery rejects', async () => {
testState.sendRuntimePtyInputVerified.mockRejectedValue(new Error('runtime timeout'))
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex'
})
await flushMicrotasks()
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
await expect(promise).resolves.toBe(false)
})
})

View File

@ -4,7 +4,6 @@ import { useAppStore } from '@/store'
import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher'
import {
isRemoteRuntimePtyId,
sendRuntimePtyInput,
sendRuntimePtyInputVerified
} from '@/runtime/runtime-terminal-inspection'
import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream'
@ -95,12 +94,15 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
sendRuntimePtyInput(
useAppStore.getState().settings,
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}${submit ? '\r' : ''}`
)
return true
try {
return await sendRuntimePtyInputVerified(
useAppStore.getState().settings,
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}${submit ? '\r' : ''}`
)
} catch {
return false
}
}
export async function submitPromptToAgentTab(args: {

View File

@ -6,6 +6,7 @@ const mockSetActiveTabType = vi.fn()
const mockSetTabBarOrder = vi.fn()
const mockSetAgentStatus = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockTrack = vi.fn()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@ -50,7 +51,7 @@ vi.mock('@/lib/agent-paste-draft', () => ({
}))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
track: mockTrack,
tuiAgentToAgentKind: (agent: string) => agent
}))
@ -88,6 +89,32 @@ describe('launchAgentInNewTab', () => {
)
})
it('does not track prompt-sent for argv prompt launches', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'fix the spinner',
launchSource: 'onboarding'
})
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track prompt-sent for draft launches', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'review this before sending',
promptDelivery: 'draft'
})
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('seeds working after Command Code submit-after-ready prompt delivery', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@ -120,5 +147,21 @@ describe('launchAgentInNewTab', () => {
prompt: 'large generated prompt',
agentType: 'command-code'
})
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track prompt-sent when submit-after-ready delivery fails', async () => {
mockPasteDraftWhenAgentReady.mockResolvedValue(false)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await Promise.resolve()
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
})

View File

@ -93,7 +93,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
// Why: argv/flag agents fold the prompt into the launch command and
// auto-submit — keeping behavior consistent with the composer/tab-bar `+`
// mental model, where the prompt is "the first turn the user sent".
@ -189,7 +188,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
request_kind: 'new'
}
})
// Why: schedule the bracketed-paste-after-ready follow-up immediately after
// the startup command is queued. Fire-and-forget so callers keep their
// synchronous `{ tabId, startupPlan }` signature. The helper short-circuits

View File

@ -1,5 +1,48 @@
import { describe, expect, it } from 'vitest'
import { getWorkspaceSeedName, isGitLabIssueUrl } from './new-workspace'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockInspectRuntimeTerminalProcess,
mockSendRuntimePtyInputVerified,
mockPasteDraftWhenAgentReady,
mockTrack,
store
} = vi.hoisted(() => ({
mockInspectRuntimeTerminalProcess: vi.fn(),
mockSendRuntimePtyInputVerified: vi.fn(),
mockPasteDraftWhenAgentReady: vi.fn(),
mockTrack: vi.fn(),
store: {
settings: {},
activeTabIdByWorktree: { 'wt-1': 'tab-1' } as Record<string, string>,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as Record<string, { id: string }[]>,
ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record<string, string[]>
}
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
}
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
inspectRuntimeTerminalProcess: mockInspectRuntimeTerminalProcess,
sendRuntimePtyInputVerified: mockSendRuntimePtyInputVerified
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
vi.mock('@/lib/telemetry', () => ({
track: mockTrack
}))
import {
ensureAgentStartupInTerminal,
getWorkspaceSeedName,
isGitLabIssueUrl
} from './new-workspace'
describe('getWorkspaceSeedName', () => {
it('prefers an explicit name', () => {
@ -124,3 +167,88 @@ describe('isGitLabIssueUrl', () => {
expect(isGitLabIssueUrl('https://github.com/group/project/issues/123')).toBe(false)
})
})
describe('ensureAgentStartupInTerminal prompt delivery', () => {
beforeEach(() => {
vi.clearAllMocks()
store.settings = {}
store.activeTabIdByWorktree = { 'wt-1': 'tab-1' }
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
store.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
mockInspectRuntimeTerminalProcess.mockResolvedValue({
foregroundProcess: 'aider',
hasChildProcesses: true
})
mockSendRuntimePtyInputVerified.mockResolvedValue(true)
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
})
it('sends a follow-up prompt through the terminal runtime without renderer telemetry', async () => {
await ensureAgentStartupInTerminal({
worktreeId: 'wt-1',
startup: {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
}
})
expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', 'fix the spinner\r')
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track when follow-up prompt delivery is rejected by the terminal runtime', async () => {
mockSendRuntimePtyInputVerified.mockResolvedValue(false)
await ensureAgentStartupInTerminal({
worktreeId: 'wt-1',
startup: {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
}
})
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track when follow-up prompt delivery rejects', async () => {
mockSendRuntimePtyInputVerified.mockRejectedValue(new Error('runtime timeout'))
await expect(
ensureAgentStartupInTerminal({
worktreeId: 'wt-1',
startup: {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
}
})
).resolves.toBeUndefined()
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track draft prompt delivery as a sent prompt', async () => {
await ensureAgentStartupInTerminal({
worktreeId: 'wt-1',
startup: {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
draftPrompt: 'review this before sending'
}
})
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({
tabId: 'tab-1',
content: 'review this before sending',
agent: 'claude'
})
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
})

View File

@ -2,7 +2,7 @@ import { useAppStore } from '@/store'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import {
inspectRuntimeTerminalProcess,
sendRuntimePtyInput
sendRuntimePtyInputVerified
} from '@/runtime/runtime-terminal-inspection'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
@ -244,7 +244,7 @@ export async function ensureAgentStartupInTerminal(args: {
// session and submitted. Wait until the agent owns the PTY before writing.
if (startup.followupPrompt) {
await waitForAgentForeground(ptyId, startup.expectedProcess)
sendRuntimePtyInput(useAppStore.getState().settings, ptyId, `${startup.followupPrompt}\r`)
await sendFollowupPrompt(ptyId, startup.followupPrompt)
}
// Why: draftPrompt uses bracketed-paste so the URL lands atomically in the
@ -259,6 +259,14 @@ export async function ensureAgentStartupInTerminal(args: {
}
}
async function sendFollowupPrompt(ptyId: string, prompt: string): Promise<boolean> {
try {
return await sendRuntimePtyInputVerified(useAppStore.getState().settings, ptyId, `${prompt}\r`)
} catch {
return false
}
}
// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with
// a hasChildProcesses fallback after several polls). Preserve that behavior so
// stdin-after-start agents still receive their prompt under the same

View File

@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inspectRuntimeTerminalProcess, sendRuntimePtyInput } from './runtime-terminal-inspection'
import {
inspectRuntimeTerminalProcess,
sendRuntimePtyInput,
sendRuntimePtyInputVerified
} from './runtime-terminal-inspection'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
@ -10,6 +14,7 @@ describe('runtime terminal owner routing', () => {
const runtimeCall = vi.fn()
const runtimeTransportCall = vi.fn()
const localWrite = vi.fn()
const localWriteAccepted = vi.fn()
const localForeground = vi.fn()
const localHasChildren = vi.fn()
@ -29,6 +34,7 @@ describe('runtime terminal owner routing', () => {
runtimeEnvironments: { call: runtimeTransportCall },
pty: {
write: localWrite,
writeAccepted: localWriteAccepted,
getForegroundProcess: localForeground,
hasChildProcesses: localHasChildren
}
@ -83,4 +89,68 @@ describe('runtime terminal owner routing', () => {
)
).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false })
})
it('reports stale remote terminal handles as rejected during verified send', async () => {
runtimeCall.mockResolvedValue({
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
})
await expect(
sendRuntimePtyInputVerified(
{ activeRuntimeEnvironmentId: 'env-2' },
'remote:env-1@@terminal-stale',
'x'
)
).resolves.toBe(false)
})
it('reports declined remote terminal sends as rejected during verified send', async () => {
runtimeCall.mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } },
_meta: { runtimeId: 'runtime-1' }
})
await expect(
sendRuntimePtyInputVerified(
{ activeRuntimeEnvironmentId: 'env-2' },
'remote:env-1@@terminal-1',
'x'
)
).resolves.toBe(false)
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
text: 'x',
client: { id: 'orca-desktop', type: 'desktop' }
},
timeoutMs: 15_000
})
})
it('uses accepted local writes for verified input', async () => {
localWriteAccepted.mockResolvedValue(true)
await expect(
sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x')
).resolves.toBe(true)
expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x')
expect(localWrite).not.toHaveBeenCalled()
})
it('reports success after fallback fire-and-forget writes when local acceptance cannot be verified', async () => {
localWriteAccepted.mockResolvedValue(false)
await expect(
sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x')
).resolves.toBe(true)
expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x')
expect(localWrite).toHaveBeenCalledWith('local-pty', 'x')
})
})

View File

@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../shared/types'
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
import { RuntimeRpcCallError, callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
@ -106,13 +107,24 @@ export async function sendRuntimePtyInputVerified(
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
window.api.pty.write(ptyId, data)
return true
const accepted = await window.api.pty.writeAccepted(ptyId, data)
if (!accepted) {
window.api.pty.write(ptyId, data)
// Why: SSH/local fallback writes are fire-and-forget. Callers use this
// boolean to continue UX flow, while hook telemetry confirms real turns.
return true
}
return accepted
}
try {
await callRuntimeRpc(target, 'terminal.send', { terminal, text: data }, { timeoutMs: 15_000 })
return true
const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
target,
'terminal.send',
{ terminal, text: data, client: { id: 'orca-desktop', type: 'desktop' } },
{ timeoutMs: 15_000 }
)
return result.send.accepted === true
} catch (error) {
if (isTerminalGoneError(error)) {
return false

View File

@ -196,6 +196,52 @@ describe('shared agent-hook-listener', () => {
toolName: 'shell_command',
toolInput: 'pwd'
})
expect(tool?.hasExplicitPrompt).toBe(true)
expect(tool?.promptInteractionKey).toMatch(/^command-code-transcript-[a-f0-9]{12}-/)
const directPrompt = normalizeHookPayload(
createHookListenerState(),
'command-code',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
prompt: 'Direct command prompt'
}
},
'production'
)
expect(directPrompt?.hasExplicitPrompt).toBe(true)
const directPromptWithTranscript = normalizeHookPayload(
createHookListenerState(),
'command-code',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
prompt: 'Run pwd and report it',
transcript_path: transcriptPath
}
},
'production'
)
expect(directPromptWithTranscript?.hasExplicitPrompt).toBe(true)
expect(directPromptWithTranscript?.promptInteractionKey).toBe(tool?.promptInteractionKey)
const statusMessage = normalizeHookPayload(
createHookListenerState(),
'command-code',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
message: 'Preparing tool call'
}
},
'production'
)
expect(statusMessage?.hasExplicitPrompt).toBe(false)
const done = normalizeHookPayload(
state,
@ -219,6 +265,25 @@ describe('shared agent-hook-listener', () => {
agentType: 'command-code',
lastAssistantMessage: 'The output is /tmp/project.'
})
expect(done?.promptInteractionKey).toBe(tool?.promptInteractionKey)
const cachedOnly = normalizeHookPayload(
state,
'command-code',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: {
hook_event_name: 'Stop'
}
},
'production'
)
expect(cachedOnly?.payload.prompt).toBe('Run pwd and report it')
expect(cachedOnly?.hasExplicitPrompt).toBe(false)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
@ -376,6 +441,7 @@ describe('shared agent-hook-listener', () => {
prompt: 'Fix the failing test',
agentType: 'antigravity'
})
expect(started?.hasExplicitPrompt).toBe(true)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}

View File

@ -11,7 +11,7 @@
// which pull `electron` — so it is safe to import from `src/relay/`. See
// docs/design/agent-status-over-ssh.md §3 ("relay normalizes; Orca routes").
import type { IncomingMessage } from 'http'
import { randomUUID } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import { homedir } from 'os'
import {
chmodSync,
@ -141,6 +141,9 @@ export type AgentHookEventPayload = {
/** True when this hook event carried prompt text directly, instead of using
* the listener's cached prompt from an earlier event in the same pane. */
hasExplicitPrompt?: boolean
/** Stable per-turn key when a source exposes enough local hook context to
* distinguish duplicate hook delivery from a same-text prompt rerun. */
promptInteractionKey?: string
/** Raw agent hook event name, used by main-process transition guards. */
hookEventName?: string
/** Claude tool-use identifier when the hook source exposes one. */
@ -235,7 +238,21 @@ export function readRequestBody(req: IncomingMessage): Promise<unknown> {
// ─── Per-pane field caches + extractors ─────────────────────────────
function extractPromptText(hookPayload: Record<string, unknown>): string {
type ExtractedPromptText = {
text: string
source:
| 'prompt'
| 'user_prompt'
| 'userPrompt'
| 'initial_prompt'
| 'initialPrompt'
| 'user_message'
| 'message'
| 'role_user_text'
| null
}
function extractPromptText(hookPayload: Record<string, unknown>): ExtractedPromptText {
const candidateKeys = [
'prompt',
'user_prompt',
@ -250,7 +267,7 @@ function extractPromptText(hookPayload: Record<string, unknown>): string {
if (typeof value === 'string' && value.trim().length > 0) {
// Why: trim so prompts match what readStringField produces elsewhere —
// surrounding whitespace would otherwise leak into UI and caches.
return value.trim()
return { text: value.trim(), source: key as Exclude<ExtractedPromptText['source'], null> }
}
}
// Why: OpenCode's plugin sends MessagePart events with { role, text }. When
@ -259,10 +276,10 @@ function extractPromptText(hookPayload: Record<string, unknown>): string {
if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') {
const trimmed = hookPayload.text.trim()
if (trimmed.length > 0) {
return trimmed
return { text: trimmed, source: 'role_user_text' }
}
}
return ''
return { text: '', source: null }
}
function stripGrokUserQueryWrapper(promptText: string): string {
@ -664,6 +681,72 @@ function extractCommandCodeUserPromptFromLine(line: string): string | undefined
return record.role === 'user' ? extractAssistantContentText(record.content) : undefined
}
function hashInteractionKeyPart(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 12)
}
function readLastCommandCodeUserPromptEntryFromTranscript(
transcriptPath: unknown
): { text: string; interactionKey: string } | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
try {
const stats = statSync(transcriptPath)
const size = stats.size
if (size <= 0) {
return undefined
}
const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES)
const position = size - bytesToRead
const fd = openSync(transcriptPath, 'r')
try {
const buffer = Buffer.alloc(bytesToRead)
let filled = 0
while (filled < bytesToRead) {
const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
let text = buffer.subarray(0, filled).toString('utf8')
let textBasePosition = position
if (position > 0) {
const firstNewline = text.indexOf('\n')
textBasePosition += firstNewline + 1
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1)
}
let lastPrompt: string | undefined
let lastPromptOffset = 0
let lineStart = 0
for (const line of text.split('\n')) {
const prompt = extractCommandCodeUserPromptFromLine(line.trim())
if (prompt !== undefined) {
lastPrompt = prompt
lastPromptOffset = textBasePosition + Buffer.byteLength(text.slice(0, lineStart), 'utf8')
}
lineStart += line.length + 1
}
return lastPrompt
? {
text: lastPrompt,
interactionKey: [
'command-code-transcript',
hashInteractionKeyPart(transcriptPath),
String(lastPromptOffset),
hashInteractionKeyPart(lastPrompt)
].join('-')
}
: undefined
} finally {
closeSync(fd)
}
} catch {
return undefined
}
}
function extractCommandCodeAssistantTextFromLine(line: string): string | undefined {
let entry: unknown
try {
@ -698,13 +781,6 @@ function extractCommandCodeAssistantTextFromLine(line: string): string | undefin
return extractAssistantContentText(content)
}
function readLastCommandCodeUserPromptFromTranscript(transcriptPath: unknown): string | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeUserPromptFromLine)
}
function readLastCommandCodeAssistantFromTranscript(transcriptPath: unknown): string | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
@ -1665,6 +1741,52 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
}
}
function hasExplicitUserPrompt(
source: AgentHookSource,
eventName: unknown,
extractedPrompt: ExtractedPromptText,
resolvedPromptText: string,
hasTranscriptPromptEvidence = false
): boolean {
if (
source === 'command-code' &&
(eventName === 'PreToolUse' || eventName === 'Stop') &&
(extractedPrompt.source !== 'message' || hasTranscriptPromptEvidence) &&
resolvedPromptText.trim().length > 0
) {
// Why: Command Code exposes the submitted prompt through its transcript
// rather than direct hook fields. Treat the transcript-backed prompt as
// explicit so hook telemetry covers real Command Code turns.
return true
}
if (
source === 'antigravity' &&
isNewTurnEvent(source, eventName) &&
resolvedPromptText.trim().length > 0
) {
return true
}
if (extractedPrompt.source === 'role_user_text') {
return source === 'opencode' && eventName === 'MessagePart'
}
if (extractedPrompt.text.length === 0) {
return false
}
// Why: bare `message` fields often contain permission or status copy. They
// may update visible status prompts, but they are not proof of user submit.
if (extractedPrompt.source === 'message') {
return false
}
if (
extractedPrompt.source === 'user_prompt' ||
extractedPrompt.source === 'userPrompt' ||
extractedPrompt.source === 'user_message'
) {
return isNewTurnEvent(source, eventName)
}
return isNewTurnEvent(source, eventName)
}
function extractToolFields(
source: AgentHookSource,
eventName: unknown,
@ -2222,12 +2344,6 @@ function normalizeCommandCodeEvent(
return null
}
const effectivePrompt =
promptText ||
readLastCommandCodeUserPromptFromTranscript(
hookPayload.transcript_path ?? hookPayload.transcriptPath
) ||
''
const snapshot = resolveToolState(
state,
paneKey,
@ -2238,7 +2354,7 @@ function normalizeCommandCodeEvent(
return parseAgentStatusPayload(
JSON.stringify({
state: stateName,
prompt: resolvePrompt(state, paneKey, effectivePrompt, {
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('command-code', eventName)
}),
agentType: 'command-code',
@ -2424,11 +2540,15 @@ export function normalizeHookPayload(
const worktreeId = readStringField(record, 'worktreeId')
const hookPayloadRecord = hookPayload as Record<string, unknown>
let promptInteractionKey: string | undefined
const eventName =
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
hookPayloadRecord.hook_event_name ??
hookPayloadRecord.hookEventName
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
const extractedPrompt = extractPromptText(hookPayload as Record<string, unknown>)
const promptText = extractedPrompt.text
let resolvedPromptText = promptText
let hasTranscriptPromptEvidence = false
// Why: exhaustive switch so adding a source to AgentHookSource fails
// typecheck here instead of silently routing through OpenCode's normalizer.
let payload: ParsedAgentStatusPayload | null
@ -2443,9 +2563,25 @@ export function normalizeHookPayload(
payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'antigravity':
if (isNewTurnEvent('antigravity', eventName)) {
resolvedPromptText =
promptText ||
readLastUserPromptFromTranscript(
readFirstString(hookPayloadRecord, ['transcriptPath', 'transcript_path'])
) ||
''
}
payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'opencode':
if (extractedPrompt.source === 'role_user_text') {
const messageId = readFirstString(hookPayloadRecord, [
'messageID',
'messageId',
'message_id'
])
promptInteractionKey = messageId ? `opencode-message-${messageId}` : undefined
}
payload = normalizeOpenCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'cursor':
@ -2475,7 +2611,24 @@ export function normalizeHookPayload(
payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'command-code':
payload = normalizeCommandCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
{
const transcriptPrompt = readLastCommandCodeUserPromptEntryFromTranscript(
hookPayloadRecord.transcript_path ?? hookPayloadRecord.transcriptPath
)
hasTranscriptPromptEvidence = transcriptPrompt !== undefined
promptInteractionKey = transcriptPrompt?.interactionKey
resolvedPromptText = transcriptPrompt?.text ?? ''
if (promptText && extractedPrompt.source !== 'message') {
resolvedPromptText = promptText
}
}
payload = normalizeCommandCodeEvent(
state,
eventName,
resolvedPromptText,
paneKey,
hookPayloadRecord
)
break
case 'grok':
payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
@ -2503,7 +2656,14 @@ export function normalizeHookPayload(
tabId,
worktreeId,
connectionId: null,
hasExplicitPrompt: promptText.length > 0,
hasExplicitPrompt: hasExplicitUserPrompt(
source,
eventName,
extractedPrompt,
resolvedPromptText,
hasTranscriptPromptEvidence
),
promptInteractionKey,
hookEventName: typeof eventName === 'string' ? eventName : undefined,
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']),

View File

@ -60,6 +60,9 @@ export type AgentHookRelayEnvelope = {
/** Preserved from the relay-side normalized hook event so Orca can
* distinguish a true same-prompt retry from a cached-prompt tool ping. */
hasExplicitPrompt?: boolean
/** Optional stable per-turn key from the relay-side listener. Used only for
* in-memory dedupe; never included in product telemetry payloads. */
promptInteractionKey?: string
/** Hook discriminator preserved for main-process transition rules. */
hookEventName?: string
/** Claude tool execution id, when the source hook provides one. */

View File

@ -111,6 +111,28 @@ describe('agent_started schema', () => {
})
})
describe('agent_prompt_sent schema', () => {
it('accepts a hook-confirmed prompt-send payload with cohort context', () => {
const parsed = eventSchemas.agent_prompt_sent.safeParse({
agent_kind: 'codex',
launch_source: 'unknown',
request_kind: 'followup',
nth_repo_added: 1
})
expect(parsed.success).toBe(true)
})
it('rejects prompt text via .strict()', () => {
const parsed = eventSchemas.agent_prompt_sent.safeParse({
agent_kind: 'claude-code',
launch_source: 'unknown',
request_kind: 'followup',
prompt: 'please inspect /Users/alice/private-repo'
})
expect(parsed.success).toBe(false)
})
})
describe('agent_hook_unattributed schema', () => {
it('accepts the two bounded attribution failure reasons', () => {
for (const reason of ['empty_pane_key', 'unknown_tab_id'] as const) {

View File

@ -281,6 +281,14 @@ const agentStartedSchema = z
nth_repo_added: nthRepoAddedSchema
})
.strict()
const agentPromptSentSchema = z
.object({
agent_kind: agentKindSchema,
launch_source: launchSourceSchema,
request_kind: requestKindSchema,
nth_repo_added: nthRepoAddedSchema
})
.strict()
// Enum-only by design for both fields. `error_message` and `error_stack` are
// deliberately absent — `.strict()` rejects either key if a call site ever
@ -1017,6 +1025,7 @@ export const eventSchemas = {
setup_script_prompt_action: setupScriptPromptActionSchema,
agent_started: agentStartedSchema,
agent_prompt_sent: agentPromptSentSchema,
agent_error: agentErrorSchema,
agent_hook_install_failed: agentHookInstallFailedSchema,
agent_hook_unattributed: agentHookUnattributedSchema,
@ -1107,6 +1116,7 @@ type _CohortExtendedRoster =
| 'setup_script_prompt_shown'
| 'setup_script_prompt_action'
| 'agent_started'
| 'agent_prompt_sent'
| 'agent_error'
// Why: `z.object({}).strict()` infers a string index signature, which would
// make every key appear present. Ignore index-signature-only keys here so