From 8a3db9db51aa2b1daf23a57c5f810279fe6faef8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:52:54 -0800 Subject: [PATCH] fix(cli): keep agent show/read work bounded by the agent they name --- docs/site/content/docs/cli/reference.mdx | 2 +- skill-guides/orca-cli.md | 2 +- src/cli/agents/agent-conversations.ts | 12 ++- src/cli/skills/bundled-skill-guides.ts | 2 +- src/cli/specs/agent-conversations.ts | 2 +- .../agent-identity-client.test-fixtures.ts | 2 + .../identity/agent-identity-demand.ts | 13 ++- .../identity/agent-identity-lookup-queue.ts | 85 +++++++++++------ .../identity/agent-identity-worker-entry.ts | 37 ++++++-- .../identity/pane-agent-identities.test.ts | 54 +++++++++++ .../identity/pane-agent-identities.ts | 6 +- .../server/server-agent-identities.ts | 14 ++- .../server-agent-transcript-source.test.ts | 28 ++++-- ...ctured-agent-session-conversation-reads.ts | 2 + .../host/structured-agent-session-host.ts | 2 +- .../native-chat/transcript-tail-reader.ts | 12 ++- .../transcript-tail-scan-bound.test.ts | 48 ++++++++++ .../agent-conversation-address.ts | 2 +- .../agent-conversation-list.test.ts | 57 ++++++++++++ .../agent-conversation-list.ts | 59 ++++++++++++ .../agent-conversation-read.test.ts | 93 +++++++++++++++++++ .../agent-conversation-read.ts | 72 +++++++++----- ...ructured-agent-session-mobile-attention.ts | 2 +- .../worker-transcript-local-read.ts | 13 ++- .../agent-conversation-methods.ts | 10 +- .../session-hosts/get-runtime-id.ts | 28 +++--- .../agent-conversation-contracts.ts | 10 +- 27 files changed, 561 insertions(+), 108 deletions(-) create mode 100644 src/main/native-chat/transcript-tail-scan-bound.test.ts create mode 100644 src/main/runtime/agent-conversations/agent-conversation-list.test.ts create mode 100644 src/main/runtime/agent-conversations/agent-conversation-read.test.ts diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 7521b07d8f5..97ec519a09e 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -249,7 +249,7 @@ orca agent read orca_session_id:claude_1234 --limit 10 orca agent read orca_session_id:claude_1234 --cursor --json ``` -Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`), what Copy Orca Session ID copies on a chat and Copy Agent ID on a terminal. `agent list` lists the agents open in this host's workspaces with their IDs; a terminal agent shows none until it first reports to Orca. A chat keeps its ID across `/clear`; a terminal agent gets a new one. IDs belong to the host that runs the agent: agents on other hosts are not listed, and the output names those hosts with the `--environment` that lists them. +Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`), what Copy Orca Session ID copies. `agent list` lists the agents open in this host's workspaces with their IDs; a terminal agent shows none until it first reports to Orca. A chat keeps its ID across `/clear`; a terminal agent gets a new one. IDs belong to the host that runs the agent: agents on other hosts are not listed, and the output names those hosts with the `--environment` that lists them. `agent read` reads the conversation, not a screen: a chat's history, or a terminal agent's own transcript. The agent need not be running and reading never starts it. A terminal agent can be read while Orca knows where its transcript is (it is open, or asleep in a workspace). Pass the printed `cursor` back with `--cursor` to read only what came after. To message an agent, use `orca terminal send` or `orca orchestration send`. diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index 3a26a1319a6..ab58ed1a525 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -70,7 +70,7 @@ If a launch reply is lost, use the returned `operationId` to re-issue the exact ## Agents by ID -Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`; what Copy Orca Session ID copies on a chat and Copy Agent ID on a terminal). Use it to find and read agents: +Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`; what Copy Orca Session ID copies). Use it to find and read agents: - `ORCA agent list --json` lists the agents open in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use. - `ORCA agent show --json` shows where one agent is open (none if closed, two if resumed in two panes). diff --git a/src/cli/agents/agent-conversations.ts b/src/cli/agents/agent-conversations.ts index 9f91afd42aa..79951d85c8c 100644 --- a/src/cli/agents/agent-conversations.ts +++ b/src/cli/agents/agent-conversations.ts @@ -18,6 +18,7 @@ import { type AgentConversationRow, type AgentListResult, type AgentReadResult, + type AgentReadTool, type AgentShowResult } from '../../shared/agent-conversations/agent-conversation-contracts' import { AGENT_STATUS_STATES } from '../../shared/agent-status/contracts/agent-status-types' @@ -143,16 +144,23 @@ function formatAgentShow(result: AgentShowResult): string { return result.places.map(formatAgentRow).join('\n\n') } +function formatTool(tool: AgentReadTool): string { + return `[tool ${tool.name}${tool.state ? `: ${tool.state}` : ''}]` +} + function formatAgentRead(result: AgentReadResult): string { const lines = result.entries.map((entry) => { if (entry.kind === 'message') { - return `${entry.role}: ${entry.text}` + return [ + ...(entry.text ? [`${entry.role}: ${entry.text}`] : []), + ...(entry.tools ?? []).map((tool) => formatTool(tool)) + ].join('\n') } if (entry.kind === 'error') { return `[error] ${entry.text}` } if (entry.kind === 'tool') { - return `[tool ${entry.name}${entry.state ? `: ${entry.state}` : ''}]` + return formatTool(entry) } return `[${entry.kind}${entry.pending ? ', waiting for an answer' : ''}] ${entry.text}` }) diff --git a/src/cli/skills/bundled-skill-guides.ts b/src/cli/skills/bundled-skill-guides.ts index 21024a36d26..5762470da37 100644 --- a/src/cli/skills/bundled-skill-guides.ts +++ b/src/cli/skills/bundled-skill-guides.ts @@ -298,7 +298,7 @@ const ORCA_CLI_MARKDOWN = [ "", "## Agents by ID", "", - "Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`; what Copy Orca Session ID copies on a chat and Copy Agent ID on a terminal). Use it to find and read agents:", + "Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`; what Copy Orca Session ID copies). Use it to find and read agents:", "", "- `ORCA agent list --json` lists the agents open in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use.", "- `ORCA agent show --json` shows where one agent is open (none if closed, two if resumed in two panes).", diff --git a/src/cli/specs/agent-conversations.ts b/src/cli/specs/agent-conversations.ts index 85754a6b522..bbb0fd8ca67 100644 --- a/src/cli/specs/agent-conversations.ts +++ b/src/cli/specs/agent-conversations.ts @@ -22,7 +22,7 @@ export const AGENT_CONVERSATION_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS, 'id'], positionalArgs: ['id'], notes: [ - ' is the `orca_session_id:` ID from `orca agent list`, Copy Orca Session ID on a chat or Copy Agent ID on a terminal.', + ' is the `orca_session_id:` ID from `orca agent list` or Copy Orca Session ID.', 'An agent resumed in two panes shows both; one that is not open shows none.' ], examples: ['orca agent show orca_session_id:claude_1234 --json'] diff --git a/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts b/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts index 7d0e280b557..58226ec93a6 100644 --- a/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts +++ b/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts @@ -14,6 +14,7 @@ import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-sessio export class InProcessAgentIdentityClient implements AgentIdentityClient { identifyRequests = 0 hasRequests = 0 + providerKeyRequests = 0 /** When set, `identify` waits for `release()` before answering. */ hold = false /** Answers the next N identify requests as unavailable after running their SQL. */ @@ -47,6 +48,7 @@ export class InProcessAgentIdentityClient implements AgentIdentityClient { path: string, orcaSessionId: string ): Promise<{ key: AgentProviderKey | null } | undefined> { + this.providerKeyRequests++ const response = handleAgentIdentityRequest({ id: 0, kind: 'providerKeyOf', diff --git a/src/main/agent-hooks/identity/agent-identity-demand.ts b/src/main/agent-hooks/identity/agent-identity-demand.ts index 57972f1692e..3f498530ac6 100644 --- a/src/main/agent-hooks/identity/agent-identity-demand.ts +++ b/src/main/agent-hooks/identity/agent-identity-demand.ts @@ -117,11 +117,12 @@ export class AgentIdentityDemand { } /** Membership shares the failure gate: during the backoff it answers unavailable at once. */ - has = (id: string) => this.lookup((client, path) => client.has(path, id)) + has = (id: string) => this.lookup(`has\0${id}`, (client, path) => client.has(path, id)) - /** An explicit read-only question under the store's one-at-a-time queue and failure gate; - * undefined when the store cannot answer now. A sent question leaves `lookups`. */ + /** An explicit read-only question under the store's one-at-a-time queue and failure gate, + * shared by simultaneous callers of one `key`; undefined when the store cannot answer now. */ async lookup( + key: string, run: (client: AgentIdentityClient, path: string) => Promise ): Promise { this.syncAuthority() @@ -129,9 +130,11 @@ export class AgentIdentityDemand { return undefined } const generation = this.generationValue - const answer = this.lookups.enqueue(run) + const pending = this.lookups.enqueue(key, run) this.schedulePump() - return generation === this.generationValue ? await answer : undefined + const answer = await pending + // An answer accepted before the owner closed is not this generation's to give. + return generation === this.generationValue ? answer : undefined } /** A different store path is a different authority: nothing carries across. */ diff --git a/src/main/agent-hooks/identity/agent-identity-lookup-queue.ts b/src/main/agent-hooks/identity/agent-identity-lookup-queue.ts index 8e236a6f2d1..91cc93b406f 100644 --- a/src/main/agent-hooks/identity/agent-identity-lookup-queue.ts +++ b/src/main/agent-hooks/identity/agent-identity-lookup-queue.ts @@ -1,49 +1,73 @@ import type { AgentIdentityClient } from './agent-identity-worker-client' -/** One explicit read-only question: `send` resolves true when the store could not answer. */ +/** One explicit read-only question, shared by every caller asking it: `send` resolves true when + * the store could not answer. */ export type AgentIdentityLookup = { send: (client: AgentIdentityClient, path: string) => Promise - cancel: () => void } -/** Questions for the identity store's one-at-a-time queue; each caller waits at most `waitMs`, - * and a question already sent may finish after its caller stopped waiting. */ +type PendingLookup = { + lookup: AgentIdentityLookup + waiters: Set<(value: unknown) => void> + sent: boolean +} + +/** Questions for the identity store's one-at-a-time queue, keyed so simultaneous callers of one + * question share one worker operation. Each caller waits at most `waitMs`; a question already + * sent may finish after its callers stopped waiting. Nothing is kept once answered. */ export class AgentIdentityLookupQueue { - /** Callers still waiting, in arrival order; a sent question stays until it is answered. */ - private readonly waiting = new Set() - private readonly sent = new WeakSet() + /** Questions with callers still waiting, in arrival order; a sent one stays until answered. */ + private readonly pending = new Map() constructor(private readonly waitMs: number) {} + /** `key` names the question: one key always runs the same `run`, so it answers with `T`. */ enqueue( + key: string, run: (client: AgentIdentityClient, path: string) => Promise ): Promise { + let entry = this.pending.get(key) + if (!entry) { + const created: PendingLookup = { + waiters: new Set(), + sent: false, + lookup: { + send: async (client, path) => { + const value = await run(client, path) + this.answer(key, created, value) + return value === undefined + } + } + } + entry = created + this.pending.set(key, entry) + } + const shared = entry return new Promise((resolve) => { - const done = (value: T | undefined): void => { + const waiter = (value: unknown): void => { clearTimeout(timer) - this.waiting.delete(lookup) - resolve(value) + shared.waiters.delete(waiter) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a key always names the same question, so its answer is this caller's `T`. + resolve(value as T | undefined) } - const lookup: AgentIdentityLookup = { - send: async (client, path) => { - const value = await run(client, path) - done(value) - return value === undefined - }, - cancel: () => done(undefined) - } - const timer = setTimeout(lookup.cancel, this.waitMs) + const timer = setTimeout(() => { + waiter(undefined) + // An unsent question nobody waits for is never sent. + if (!shared.sent && shared.waiters.size === 0 && this.pending.get(key) === shared) { + this.pending.delete(key) + } + }, this.waitMs) timer.unref() - this.waiting.add(lookup) + shared.waiters.add(waiter) }) } /** The oldest question not yet sent, marked sent. */ next(): AgentIdentityLookup | undefined { - for (const lookup of this.waiting) { - if (!this.sent.has(lookup)) { - this.sent.add(lookup) - return lookup + for (const entry of this.pending.values()) { + if (!entry.sent) { + entry.sent = true + return entry.lookup } } return undefined @@ -51,8 +75,17 @@ export class AgentIdentityLookupQueue { /** An ended store authority: every waiting caller gets no answer now. */ cancelAll(): void { - for (const lookup of this.waiting) { - lookup.cancel() + for (const [key, entry] of this.pending) { + this.answer(key, entry, undefined) + } + } + + private answer(key: string, entry: PendingLookup, value: unknown): void { + if (this.pending.get(key) === entry) { + this.pending.delete(key) + } + for (const waiter of entry.waiters) { + waiter(value) } } } diff --git a/src/main/agent-hooks/identity/agent-identity-worker-entry.ts b/src/main/agent-hooks/identity/agent-identity-worker-entry.ts index cba281dbc88..a0f444ee7dd 100644 --- a/src/main/agent-hooks/identity/agent-identity-worker-entry.ts +++ b/src/main/agent-hooks/identity/agent-identity-worker-entry.ts @@ -30,11 +30,15 @@ CREATE TABLE IF NOT EXISTS provider_links( const SELECT_LINK = `SELECT orca_session_id FROM provider_links WHERE execution_host = ? AND wsl_distro = ? AND agent = ? AND provider_session_id = ? AND locator = ?` -// Reverse lookups for `orca agent` reads; indexes only, so older builds open the file unchanged. +// Reverse lookups for `orca agent` reads: indexes only, so older builds open the file unchanged. const LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS provider_links_by_orca_session ON provider_links(orca_session_id); CREATE INDEX IF NOT EXISTS provider_links_by_provider_session ON provider_links(provider_session_id);` +const LOOKUP_INDEX_NAMES = [ + 'provider_links_by_orca_session', + 'provider_links_by_provider_session' +] as const const PROVIDER_SESSION_ID_MATCH_LIMIT = 8 /** Orca ids for provider conversations, which nothing can rebuild: the file is never deleted, and @@ -45,6 +49,9 @@ export class AgentIdentityStore { /** A newer build's file: existing ids resolve, nothing is minted. */ readonly readOnly: boolean + /** Reverse lookups run only on their indexes, never as a scan of every link. */ + private lookupIndexed = false + constructor(path: string, busyTimeoutMs = 25) { const opened = openIdentityDatabase(path, busyTimeoutMs) this.db = opened.db @@ -52,6 +59,26 @@ export class AgentIdentityStore { hardenSqliteDatabaseFiles(path) } + /** Adds the lookup indexes on first use; a busy or read-only file throws, so the caller's + * failure backoff retries it rather than scanning. */ + private requireLookupIndexes(): void { + if (this.lookupIndexed) { + return + } + if (!this.readOnly) { + this.db.exec(LOOKUP_INDEX_SQL) + } + const present = this.db + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'index' AND name IN (${LOOKUP_INDEX_NAMES.map(() => '?').join(', ')})` + ) + .get(...LOOKUP_INDEX_NAMES) + if (Number(present?.count) !== LOOKUP_INDEX_NAMES.length) { + throw new Error('agent identity lookup indexes unavailable') + } + this.lookupIndexed = true + } + lookup(key: AgentProviderKey): OrcaSessionId | null { const row = this.db.prepare(SELECT_LINK).get(...keyBindings(key)) const id = row?.orca_session_id @@ -105,6 +132,7 @@ export class AgentIdentityStore { /** The provider conversation `id` names: each mint links exactly one key. */ providerKeyOf(id: string): AgentProviderKey | null { + this.requireLookupIndexes() const row = this.db .prepare( `SELECT execution_host, wsl_distro, agent, provider_session_id, locator @@ -124,6 +152,7 @@ export class AgentIdentityStore { /** Orca ids linked to a provider's own session id, so a caller can be told which to use. */ idsOfProviderSession(providerSessionId: string): OrcaSessionId[] { + this.requireLookupIndexes() return this.db .prepare( `SELECT DISTINCT orca_session_id FROM provider_links WHERE provider_session_id = ? LIMIT ${PROVIDER_SESSION_ID_MATCH_LIMIT}` @@ -176,12 +205,6 @@ function openIdentityDatabase( throw error } } - try { - db.exec(LOOKUP_INDEX_SQL) - } catch (error) { - // A busy file still answers lookups, by scan; the next open adds the indexes. - console.warn('[agent-hooks] agent identity lookup indexes deferred', error) - } return { db, readOnly: false } } catch (error) { db.close() diff --git a/src/main/agent-hooks/identity/pane-agent-identities.test.ts b/src/main/agent-hooks/identity/pane-agent-identities.test.ts index 95956be771a..3e6c921e1d7 100644 --- a/src/main/agent-hooks/identity/pane-agent-identities.test.ts +++ b/src/main/agent-hooks/identity/pane-agent-identities.test.ts @@ -9,6 +9,7 @@ import { isOrcaSessionId } from '../../../shared/orca-session-address' import SyncDatabase from '../../sqlite/sync-database' import { spawnWorkerThread } from '../../worker-threads/worker-thread-spawn' import { AgentIdentityWorkerClient } from './agent-identity-worker-client' +import { AgentIdentityStore } from './agent-identity-worker-entry' import { InProcessAgentIdentityClient, ScriptedIdentityWorkers @@ -414,3 +415,56 @@ it('answers unavailable when the worker cannot load, or is stopped while loading // No worker outlives its owner. await vi.waitFor(() => expect(workers.every(exited)).toBe(true)) }) + +describe('PaneAgentIdentities explicit lookups', () => { + it.each([12, 120])( + 'asks the store once for %i simultaneous callers of one id', + async (callers) => { + const client = new InProcessAgentIdentityClient() + const { identities } = inProcessService(databasePath(), client) + const id = await identities.identify(sighting({ id: 'dormant' })) + client.hasRequests = 0 + + const answers = await Promise.all( + Array.from({ length: callers }, () => identities.isTerminalIdentity(id!)) + ) + + expect(answers.every((known) => known === true)).toBe(true) + expect(client.hasRequests).toBe(1) + } + ) + + it('answers reverse lookups only through their indexes, once a busy writer lets them be built', () => { + const path = databasePath() + const store = new AgentIdentityStore(path) + opened.push(store) + const id = store.identify({ + executionHost: 'local', + wslDistro: '', + agent: 'claude', + providerSessionId: 'session-a', + locator: '' + }) + const writer = new SyncDatabase(path) + opened.push(writer) + writer.exec('BEGIN IMMEDIATE') + // Never a scan of every link: a store that cannot build the indexes cannot answer yet. + expect(() => store.providerKeyOf(id!)).toThrow() + writer.exec('COMMIT') + + expect(store.providerKeyOf(id!)).toMatchObject({ providerSessionId: 'session-a' }) + expect(store.idsOfProviderSession('session-a')).toEqual([id]) + const plans = [ + 'SELECT locator FROM provider_links WHERE orca_session_id = ?', + 'SELECT DISTINCT orca_session_id FROM provider_links WHERE provider_session_id = ?' + ].map((sql) => + writer + .prepare(`EXPLAIN QUERY PLAN ${sql}`) + .all('x') + .map((row) => String(row.detail)) + .join('; ') + ) + expect(plans[0]).toContain('USING INDEX provider_links_by_orca_session') + expect(plans[1]).toContain('USING INDEX provider_links_by_provider_session') + }) +}) diff --git a/src/main/agent-hooks/identity/pane-agent-identities.ts b/src/main/agent-hooks/identity/pane-agent-identities.ts index 4757a00d01c..206c9935485 100644 --- a/src/main/agent-hooks/identity/pane-agent-identities.ts +++ b/src/main/agent-hooks/identity/pane-agent-identities.ts @@ -168,11 +168,13 @@ export class PaneAgentIdentities { /** The provider conversation this host linked `id` to: `{ key: null }` for none, undefined * when the store cannot answer now. An explicit lookup, never per hook. */ async providerKeyOf(id: string): Promise<{ key: AgentProviderKey | null } | undefined> { - return this.demand.lookup((client, path) => client.providerKeyOf(path, id)) + return this.demand.lookup(`providerKeyOf\0${id}`, (client, path) => + client.providerKeyOf(path, id) + ) } async idsOfProviderSession(providerSessionId: string): Promise { - return this.demand.lookup((client, path) => + return this.demand.lookup(`idsOfProviderSession\0${providerSessionId}`, (client, path) => client.idsOfProviderSession(path, providerSessionId) ) } diff --git a/src/main/agent-hooks/server/server-agent-identities.ts b/src/main/agent-hooks/server/server-agent-identities.ts index 5422ac7cbc5..16a40be862f 100644 --- a/src/main/agent-hooks/server/server-agent-identities.ts +++ b/src/main/agent-hooks/server/server-agent-identities.ts @@ -196,17 +196,20 @@ export abstract class AgentHookServerAgentIdentities extends AgentHookServerStat /** Where terminal agent `id`'s transcript is: a current row carrying the id, in any state (a read * needs no running agent), else a sleeping record naming its provider key. The session id and * path come from one payload, never from a pane, so a row attributed to the wrong pane still - * names its own conversation. null: nothing names it now; undefined: the store cannot answer. */ + * names its own conversation. null: nothing names it now; undefined: the store cannot answer. + * `knownKey` is the key an earlier answer gave: an id is never relinked, so a recheck needs no + * second store question. */ async terminalAgentTranscriptSource( id: string, - listSleeping: () => readonly SleepingAgentSighting[] + readSleeping: () => Readonly>, + knownKey?: AgentProviderKey ): Promise { const authority = this.agentIdentities.authority() const live = this.liveTranscriptSource(id) if (live) { return live } - const linked = await this.agentIdentities.providerKeyOf(id) + const linked = knownKey ? { key: knownKey } : await this.agentIdentities.providerKeyOf(id) if (!linked || authority !== this.agentIdentities.generation) { return undefined } @@ -215,7 +218,10 @@ export abstract class AgentHookServerAgentIdentities extends AgentHookServerStat if (!key || process.platform === 'win32') { return null } - for (const record of listSleeping()) { + for (const record of Object.values(readSleeping())) { + if (record.providerSession.id !== key.providerSessionId) { + continue + } const recordKey = agentProviderKeyOf({ agent: record.agent, providerSession: record.providerSession, diff --git a/src/main/agent-hooks/server/testing/identity/server-agent-transcript-source.test.ts b/src/main/agent-hooks/server/testing/identity/server-agent-transcript-source.test.ts index 5ff0a00a5e7..3cebd5216c8 100644 --- a/src/main/agent-hooks/server/testing/identity/server-agent-transcript-source.test.ts +++ b/src/main/agent-hooks/server/testing/identity/server-agent-transcript-source.test.ts @@ -20,8 +20,10 @@ afterEach(() => { }) class IdentityTestServer extends AgentHookServer { + readonly identityClient = new InProcessAgentIdentityClient() + protected override createAgentIdentityClient(): AgentIdentityClient { - return new InProcessAgentIdentityClient() + return this.identityClient } } @@ -70,12 +72,12 @@ it('reads a terminal agent by its own conversation, never by the pane it was att expect(second).toMatch(/^claude_/) expect(second).not.toBe(first) - expect(await server.terminalAgentTranscriptSource(second!, () => [])).toMatchObject({ + expect(await server.terminalAgentTranscriptSource(second!, () => ({}))).toMatchObject({ key: { wslDistro: 'Ubuntu', agent: 'claude', providerSessionId: 'session-b' }, providerSession: { id: 'session-b', transcriptPath: '/home/u/.claude/projects/p/b.jsonl' } }) // The first conversation's pane now shows the second: it has no transcript location, not b's. - expect(await server.terminalAgentTranscriptSource(first!, () => [])).toBeNull() + expect(await server.terminalAgentTranscriptSource(first!, () => ({}))).toBeNull() expect(await server.terminalAgentIdsOfProviderSession('session-a')).toEqual([first]) }) @@ -97,15 +99,23 @@ it.skipIf(process.platform === 'win32')( connectionId: WSL }) - expect(await server.terminalAgentTranscriptSource(id!, () => [sleeping('other')])).toBeNull() expect( - await server.terminalAgentTranscriptSource(id!, () => [ - sleeping('other'), - sleeping('session-a') - ]) - ).toMatchObject({ + await server.terminalAgentTranscriptSource(id!, () => ({ other: sleeping('other') })) + ).toBeNull() + let partitionReads = 0 + const records = () => { + partitionReads++ + return { other: sleeping('other'), a: sleeping('session-a') } + } + server.identityClient.providerKeyRequests = 0 + const found = await server.terminalAgentTranscriptSource(id!, records) + expect(found).toMatchObject({ key: { wslDistro: 'Ubuntu', providerSessionId: 'session-a' }, providerSession: { transcriptPath: '/home/u/.claude/projects/p/session-a.jsonl' } }) + // The post-read recheck reuses the key it was given: one partition read, no store question. + expect(await server.terminalAgentTranscriptSource(id!, records, found!.key)).toEqual(found) + expect(server.identityClient.providerKeyRequests).toBe(1) + expect(partitionReads).toBe(2) } ) diff --git a/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversation-reads.ts b/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversation-reads.ts index d1312fd9201..c6aac15623b 100644 --- a/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversation-reads.ts +++ b/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversation-reads.ts @@ -57,6 +57,8 @@ export function createStructuredAgentSessionConversationReads(context: () => Rea return { journalCurrentRead: async (sessionId: string, inputIds?: readonly string[]) => (await context().conversation(sessionId)).journal.currentRead(inputIds), + /** The journal's head, opened at rest: a reader skips an unchanged history. */ + journalCursor: async (sessionId: string) => (await journalOf(sessionId)).cursor(), readModelCatalog, attentionReads: { diff --git a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts index 1d4ed8ab39b..7ee8c5fa531 100644 --- a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts @@ -145,7 +145,7 @@ export class StructuredAgentSessionHost { hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) loadedSessionIds = this.sessions.loadedSessionIds hasLoadedProviders = (): boolean => this.loadedSessionIds().length > 0 - private readonly reads = createStructuredAgentSessionConversationReads(() => ({ + readonly reads = createStructuredAgentSessionConversationReads(() => ({ deps: this.deps, sessions: this.sessions, serialize: this.serialize, diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index e6f32d41d6c..924b57020cf 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -61,7 +61,9 @@ export async function readNativeChatTranscriptTailFile( decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, signal?: AbortSignal, onConsumedBoundary?: (boundary: Buffer) => void, - sourceOrder = false + sourceOrder = false, + /** Stop looking back after this many bytes, so a tail with few messages costs a bounded read. */ + maxScanBytes = Number.POSITIVE_INFINITY ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -109,9 +111,10 @@ export async function readNativeChatTranscriptTailFile( if (cursor === 0) { onConsumedBoundary?.(finalBoundaryByte) } - while (cursor > 0 && newestFirst.length <= limit) { + const scanFloor = Math.max(0, cursor - maxScanBytes) + while (cursor > scanFloor && newestFirst.length <= limit) { signal?.throwIfAborted() - const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const start = Math.max(scanFloor, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await readTranscriptFile( handle, @@ -168,7 +171,8 @@ export async function readNativeChatTranscriptTailFile( messages: selected.map((entry) => entry.message), ...(lifecycle ? { lifecycle } : {}), consumedTo, - hasMore: limit > 0 && chronological.length > limit, + // A scan stopped at its byte bound may have left older messages unread. + hasMore: limit > 0 && (chronological.length > limit || (cursor > 0 && cursor === scanFloor)), beforeOffset: selected[0]?.offset ?? end, ...(Number.isFinite(latest) ? { latestTimestamp: latest } : {}), ...(malformedRecordCount > 0 ? { malformedRecordCount } : {}), diff --git a/src/main/native-chat/transcript-tail-scan-bound.test.ts b/src/main/native-chat/transcript-tail-scan-bound.test.ts new file mode 100644 index 00000000000..f3e9fda27c1 --- /dev/null +++ b/src/main/native-chat/transcript-tail-scan-bound.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +// 128-byte records an agent writes between messages, none of them displayable. +const PROGRESS = `${JSON.stringify({ type: 'progress', data: 'x'.repeat(98) })}\n` + +it.each([2, 20])( + 'looks back a bounded number of bytes over a %i× larger tail with no messages', + async (multiple) => { + const directory = mkdtempSync(join(tmpdir(), 'transcript-tail-scan-')) + directories.push(directory) + const filePath = join(directory, 'session.jsonl') + const scanBytes = 64 * 1024 + writeFileSync(filePath, PROGRESS.repeat((multiple * scanBytes) / PROGRESS.length)) + let decoded = 0 + + const page = await readNativeChatTranscriptTailFile( + filePath, + 20, + () => { + decoded++ + return null + }, + false, + undefined, + undefined, + undefined, + undefined, + false, + scanBytes + ) + + expect(PROGRESS.length).toBe(128) + // The last 64 KiB of records, however long the file. + expect(decoded).toBe(512) + expect(page).toMatchObject({ messages: [], hasMore: true }) + } +) diff --git a/src/main/runtime/agent-conversations/agent-conversation-address.ts b/src/main/runtime/agent-conversations/agent-conversation-address.ts index 90ef7ec6f21..b0be6343989 100644 --- a/src/main/runtime/agent-conversations/agent-conversation-address.ts +++ b/src/main/runtime/agent-conversations/agent-conversation-address.ts @@ -84,7 +84,7 @@ export async function resolveAgentConversation( ) : new AgentConversationRefusal( CODES.invalidId, - `${text || 'That'} is not an Orca session ID. Find agents' IDs with \`orca agent list\`, or copy one with Copy Orca Session ID (Copy Agent ID on a terminal).` + `${text || 'That'} is not an Orca session ID. Find agents' IDs with \`orca agent list\`, or copy one with Copy Orca Session ID.` ) } diff --git a/src/main/runtime/agent-conversations/agent-conversation-list.test.ts b/src/main/runtime/agent-conversations/agent-conversation-list.test.ts new file mode 100644 index 00000000000..f91c1d4c453 --- /dev/null +++ b/src/main/runtime/agent-conversations/agent-conversation-list.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import { agentConversationPlaces } from './agent-conversation-list' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' + +const TERMINAL = testOrcaSessionId('claude_5688af50_12a0_4b0b_9a4a_098d87fe1e68') +const hookServer = vi.hoisted(() => ({ + terminalAgentPlaces: vi.fn(), + getPublicStatusSnapshotForPane: vi.fn() +})) +vi.mock('../../agent-hooks/server/agent-hook-server', () => ({ agentHookServer: hookServer })) + +function status(paneKey: string, orcaSessionId: string) { + return { + paneKey, + worktreeId: 'repo::/workspace', + orcaSessionId, + agentType: 'claude', + state: 'working', + receivedAt: 5 + } +} + +it.each([750, 7_500])( + 'shows one agent from its own panes, whatever the other %i panes run', + async (unrelatedPanes) => { + const panes = ['tab-a:1', 'tab-b:1'] + hookServer.terminalAgentPlaces.mockResolvedValue(panes.map((paneKey) => ({ paneKey }))) + hookServer.getPublicStatusSnapshotForPane.mockReset() + const rows = new Map( + Array.from({ length: unrelatedPanes }, (_, index) => [ + `tab-${index}:1`, + [status(`tab-${index}:1`, `claude_other_${index}`)] + ]) + ) + for (const paneKey of panes) { + rows.set(paneKey, [status(paneKey, TERMINAL)]) + } + hookServer.getPublicStatusSnapshotForPane.mockImplementation( + (paneKey: string) => rows.get(paneKey) ?? [] + ) + const runtime = { + getWorkspaceDisplayName: vi.fn(() => 'Workspace'), + getWorktreePs: vi.fn() + } + + const places = await agentConversationPlaces(runtime, { + kind: 'terminal', + orcaSessionId: TERMINAL + }) + + expect(places.map((place) => place.paneKey)).toEqual(panes) + expect(places[0]).toMatchObject({ workspace: 'Workspace', state: 'working', kind: 'terminal' }) + expect(hookServer.getPublicStatusSnapshotForPane).toHaveBeenCalledTimes(2) + expect(runtime.getWorkspaceDisplayName).toHaveBeenCalledTimes(2) + expect(runtime.getWorktreePs).not.toHaveBeenCalled() + } +) diff --git a/src/main/runtime/agent-conversations/agent-conversation-list.ts b/src/main/runtime/agent-conversations/agent-conversation-list.ts index 49fc7b48568..5e52c319876 100644 --- a/src/main/runtime/agent-conversations/agent-conversation-list.ts +++ b/src/main/runtime/agent-conversations/agent-conversation-list.ts @@ -5,6 +5,13 @@ import type { import type { AgentStatusState } from '../../../shared/agent-status/contracts/agent-status-types' import type { RuntimeWorktreePsResult } from '../../../shared/runtime-worktree-contracts' import { structuredAgentSessionIdFromTabId } from '../../../shared/agent-session/view/structured-agent-session-tab-id' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../shared/agent-session/view/structured-agent-session-projection' +import { resolveLineageRunningSession } from '../orchestration/addressing/session-lineage/structured-session-lineage' +import { agentHookServer } from '../../agent-hooks/server/agent-hook-server' +import type { ResolvedAgentConversation } from './agent-conversation-address' /** `worktree.ps` agent rows as one flat list of chats and terminal agents, oldest state first. */ export function projectAgentConversationList( @@ -42,3 +49,55 @@ export function projectAgentConversationList( ...(ps.hostScope ? { hostScope: ps.hostScope } : {}) } } + +/** Where one resolved conversation is open now, from its own keyed status rows: never a listing + * of every workspace. A closed chat or a terminal agent with no live pane has none. */ +export async function agentConversationPlaces( + runtime: { getWorkspaceDisplayName(worktreeId: string): string | null }, + resolved: ResolvedAgentConversation +): Promise { + const { orcaSessionId } = resolved + let paneKeys: string[] + if (resolved.kind === 'chat') { + // A chat's status row is keyed by the session that runs its conversation now. + const running = resolveLineageRunningSession(resolved.store, resolved.record.sessionId) + paneKeys = + running.kind === 'here' + ? [ + structuredAgentSessionPaneKey( + structuredAgentSessionTabId(running.sessionId), + running.sessionId + ) + ] + : [] + } else { + paneKeys = ((await agentHookServer.terminalAgentPlaces(orcaSessionId)) ?? []).map( + (place) => place.paneKey + ) + } + return paneKeys.flatMap((paneKey) => + agentHookServer.getPublicStatusSnapshotForPane(paneKey).flatMap((status) => { + const { worktreeId } = status + const ours = + resolved.kind === 'chat' + ? status.structuredHost !== undefined + : status.orcaSessionId === orcaSessionId + return worktreeId && ours + ? [ + { + orcaSessionId, + kind: resolved.kind, + agent: status.agentType ?? null, + state: status.state, + worktreeId, + workspace: runtime.getWorkspaceDisplayName(worktreeId) ?? worktreeId, + paneKey: status.paneKey, + title: null, + lastAssistantMessage: status.lastAssistantMessage ?? null, + updatedAt: status.evidenceObservedAt ?? status.receivedAt + } + ] + : [] + }) + ) +} diff --git a/src/main/runtime/agent-conversations/agent-conversation-read.test.ts b/src/main/runtime/agent-conversations/agent-conversation-read.test.ts new file mode 100644 index 00000000000..c1f03c58a68 --- /dev/null +++ b/src/main/runtime/agent-conversations/agent-conversation-read.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { RpcContext } from '../rpc/core' +import { readAgentConversation } from './agent-conversation-read' +import type { ResolvedAgentConversation } from './agent-conversation-address' + +const SESSION = 'codex_0b8f2a4e_6c1d_4f7a_9e3b_5a2c8d1f0e47' +const host = vi.hoisted(() => ({ + reads: { journalCursor: vi.fn() }, + history: vi.fn() +})) + +vi.mock('../rpc/methods/structured-sessions/creation/structured-agent-session-gate', () => ({ + requireStructuredAgentAudience: () => undefined, + requireInstalledStructuredHost: async () => host +})) +vi.mock('../orchestration/addressing/session-lineage/structured-session-lineage', () => ({ + resolveLineageRunningSession: () => ({ kind: 'here', sessionId: SESSION }) +})) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat read reads only the record's id and agent. +const chat = { + kind: 'chat', + orcaSessionId: SESSION, + record: { sessionId: SESSION, provider: 'codex' }, + store: {} +} as unknown as ResolvedAgentConversation +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat branch passes the context only to the mocked gates. +const ctx = {} as RpcContext + +function message(sequence: number) { + return { + itemId: `item-${sequence}`, + sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `reply ${sequence}` }] + } + } +} + +function page(items: ReturnType[], newest: number, more: boolean) { + return { + ok: true, + page: { + items, + hasOlder: true, + hasNewer: more, + window: { oldest: null, newest: null, nextCursor: { epoch: 'e', sequence: newest } }, + liveCursor: { epoch: 'e', sequence: newest } + } + } +} + +beforeEach(() => { + host.history.mockReset() + host.reads.journalCursor.mockReset() +}) + +it.each([20_000, 200_000])( + 'follows an unchanged chat of %i rows with no history read', + async (rows) => { + host.reads.journalCursor.mockResolvedValue({ epoch: 'e', sequence: rows }) + host.history.mockResolvedValue(page([message(rows)], rows, false)) + const first = await readAgentConversation(ctx, chat, { limit: 1 }) + host.history.mockClear() + + for (let read = 0; read < 10; read++) { + const again = await readAgentConversation(ctx, chat, { cursor: first.cursor! }) + expect(again).toMatchObject({ entries: [], cursor: first.cursor }) + } + + expect(host.history).not.toHaveBeenCalled() + } +) + +it('gives a cursor read at most --limit entries and continues where it stopped', async () => { + host.reads.journalCursor.mockResolvedValue({ epoch: 'e', sequence: 300 }) + host.history.mockResolvedValueOnce(page([], 100, false)) + const start = await readAgentConversation(ctx, chat, { limit: 1 }) + host.history.mockResolvedValueOnce(page([message(101)], 101, true)) + + const next = await readAgentConversation(ctx, chat, { cursor: start.cursor!, limit: 1 }) + + expect(host.history).toHaveBeenLastCalledWith({ + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'e', sequence: 100 }, + limit: 1 + }) + expect(next.entries).toEqual([{ kind: 'message', role: 'assistant', text: 'reply 101' }]) + expect(next.limited).toBe(true) +}) diff --git a/src/main/runtime/agent-conversations/agent-conversation-read.ts b/src/main/runtime/agent-conversations/agent-conversation-read.ts index 652864a3f3b..cc3ae30eb97 100644 --- a/src/main/runtime/agent-conversations/agent-conversation-read.ts +++ b/src/main/runtime/agent-conversations/agent-conversation-read.ts @@ -5,6 +5,7 @@ import { type AgentReadResult } from '../../../shared/agent-conversations/agent-conversation-contracts' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session/agent-session-wire' +import type { AgentJournalCursor } from '../../../shared/agent-session/journal/agent-session-journal-identity' import type { AgentJournalRenderItem } from '../../../shared/agent-session/journal/agent-session-journal-types' import { isRootAgentJournalItem } from '../../../shared/agent-session/journal/agent-session-journal-producer' import { agentSessionPromptQuestions } from '../../../shared/agent-session/agent-session-question-answer' @@ -23,7 +24,10 @@ import { import { createWorkerOutputSourceIdentity } from '../orchestration/workers/transcripts/worker-output-cursor' import { agentHookServer } from '../../agent-hooks/server/agent-hook-server' import type { TerminalAgentTranscriptSource } from '../../agent-hooks/server/server-agent-identities' -import { sameProviderKey } from '../../agent-hooks/identity/agent-provider-key' +import { + sameProviderKey, + type AgentProviderKey +} from '../../agent-hooks/identity/agent-provider-key' import type { ResolvedAgentConversation } from './agent-conversation-address' import { AgentConversationRefusal } from './agent-conversation-refusal' import { @@ -78,18 +82,39 @@ async function readChat( throw sourceChanged(orcaSessionId) } const host = await requireInstalledStructuredHost(ctx, sessionId) + const encode = (journal: AgentJournalCursor): string => + encodeReadCursor({ v: 1, id: orcaSessionId, s: 'chat', session: sessionId, journal }) + // A follower re-reading with its cursor costs no history work until the journal moves. + const head = cursor ? await host.reads.journalCursor(sessionId) : null + if ( + cursor && + head && + head.epoch === cursor.journal.epoch && + head.sequence <= cursor.journal.sequence + ) { + return { + orcaSessionId, + kind: 'chat', + agent: record.provider, + source: 'chat', + entries: [], + cursor: encode(cursor.journal), + limited: false + } + } + // A cursor read takes at most `limit` journal rows, each at most one entry, and continues + // from the last row it took, so none are skipped or repeated. const history = await host.history({ sessionId, direction: cursor ? 'after' : 'tail', ...(cursor ? { cursor: cursor.journal } : {}), - limit: AGENT_SESSION_HISTORY_MAX_LIMIT + limit: cursor ? limit : AGENT_SESSION_HISTORY_MAX_LIMIT }) if (!history.ok && cursor) { throw sourceChanged(orcaSessionId) } const { page } = history const all = chatEntries(page.items, page.actionablePromptIds) - // A first read is the latest entries; a cursor read is everything after it, so none are skipped. const entries = cursor ? all : all.slice(-limit) const next = cursor ? page.window.nextCursor : (page.liveCursor ?? page.window.newest) return { @@ -98,9 +123,7 @@ async function readChat( agent: record.provider, source: 'chat', entries, - cursor: next - ? encodeReadCursor({ v: 1, id: orcaSessionId, s: 'chat', session: sessionId, journal: next }) - : null, + cursor: next ? encode(next) : null, limited: cursor ? page.hasNewer : page.hasOlder || all.length > limit } } @@ -114,9 +137,13 @@ async function readTerminalTranscript( if (cursor && cursor.s !== 'transcript') { throw invalidCursor() } - const locate = (): Promise => - agentHookServer.terminalAgentTranscriptSource(orcaSessionId, () => - ctx.runtime.listSleepingAgentSessionRecords() + const locate = ( + knownKey?: AgentProviderKey + ): Promise => + agentHookServer.terminalAgentTranscriptSource( + orcaSessionId, + () => ctx.runtime.readLocalSleepingAgentSessions(), + knownKey ) const source = await locate() if (source === undefined) { @@ -165,7 +192,7 @@ async function readTerminalTranscript( transcript.sourceFingerprint ]) // The conversation must still name the file just read: its row may have moved on meanwhile. - const after = await locate() + const after = await locate(key) if ( (cursor?.s === 'transcript' && cursor.i !== sourceIdentity) || !after || @@ -245,6 +272,8 @@ function chatEntries( return entries } +/** One entry per transcript message, its tool calls folded in, so `limit` messages are at most + * `limit` entries and the cursor after them skips nothing. */ function transcriptEntries(messages: readonly NativeChatMessage[]): AgentReadEntry[] { const entries: AgentReadEntry[] = [] for (const message of messages) { @@ -256,17 +285,18 @@ function transcriptEntries(messages: readonly NativeChatMessage[]): AgentReadEnt .filter((part) => part.length > 0) .join('\n') .trim() - if (text.length > 0) { - entries.push({ kind: 'message', role: message.role, text }) - } - for (const block of message.blocks) { - if (block.type === 'tool-call') { - entries.push({ - kind: 'tool', - name: block.name, - ...(block.state ? { state: block.state } : {}) - }) - } + const tools = message.blocks.flatMap((block) => + block.type === 'tool-call' + ? [{ name: block.name, ...(block.state ? { state: block.state } : {}) }] + : [] + ) + if (text.length > 0 || tools.length > 0) { + entries.push({ + kind: 'message', + role: message.role, + text, + ...(tools.length > 0 ? { tools } : {}) + }) } } return entries diff --git a/src/main/runtime/agent-session/structured-agent-session-mobile-attention.ts b/src/main/runtime/agent-session/structured-agent-session-mobile-attention.ts index 3f0e5b72ad9..d63ed002835 100644 --- a/src/main/runtime/agent-session/structured-agent-session-mobile-attention.ts +++ b/src/main/runtime/agent-session/structured-agent-session-mobile-attention.ts @@ -51,7 +51,7 @@ export type StructuredAttentionMobileDelivery = { /** Read display labels from persisted host metadata without disk/Git lookups. */ export function readStructuredAttentionWorkspaceLabels( store: Pick, - scope: AgentSessionExecutionLocation + scope: Pick ): StructuredAttentionWorkspaceLabels { const folderScope = parseWorkspaceKey(scope.workspaceId) if (folderScope?.type === 'folder') { diff --git a/src/main/runtime/orchestration/workers/transcripts/worker-transcript-local-read.ts b/src/main/runtime/orchestration/workers/transcripts/worker-transcript-local-read.ts index 9e7ec215ba1..f7d6fa3cb57 100644 --- a/src/main/runtime/orchestration/workers/transcripts/worker-transcript-local-read.ts +++ b/src/main/runtime/orchestration/workers/transcripts/worker-transcript-local-read.ts @@ -48,7 +48,18 @@ export async function readInitialLocalWorkerTranscriptPage( if (!before) { return { ok: false, reason: 'transcript_unreadable', warnings: [] } } - const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false) + const page = await readNativeChatTranscriptTailFile( + filePath, + limit, + decode, + false, + undefined, + undefined, + undefined, + undefined, + false, + MAX_TRANSCRIPT_SCAN_BYTES + ) const after = await readLocalTranscriptSourceIdentity(filePath) if (workerTranscriptSourceChanged(before, after, page.consumedTo)) { return sourceChanged() diff --git a/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts b/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts index ac5e53a9985..7b98010f7f6 100644 --- a/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts +++ b/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts @@ -9,7 +9,10 @@ import type { AgentReadResult, AgentShowResult } from '../../../../../shared/agent-conversations/agent-conversation-contracts' -import { projectAgentConversationList } from '../../../agent-conversations/agent-conversation-list' +import { + agentConversationPlaces, + projectAgentConversationList +} from '../../../agent-conversations/agent-conversation-list' import { resolveAgentConversation } from '../../../agent-conversations/agent-conversation-address' import { readAgentConversation } from '../../../agent-conversations/agent-conversation-read' @@ -32,13 +35,10 @@ export const AGENT_CONVERSATION_METHODS = [ params: AgentShowParams, handler: async (params, { runtime }): Promise => { const resolved = await resolveAgentConversation(runtime, params.id) - const { agents } = projectAgentConversationList( - await runtime.getWorktreePs(AGENT_LIST_WORKTREE_LIMIT) - ) return { orcaSessionId: resolved.orcaSessionId, kind: resolved.kind, - places: agents.filter((row) => row.orcaSessionId === resolved.orcaSessionId) + places: await agentConversationPlaces(runtime, resolved) } } }), diff --git a/src/main/runtime/workspaces/session-hosts/get-runtime-id.ts b/src/main/runtime/workspaces/session-hosts/get-runtime-id.ts index a6f7815ed7c..0129c21a084 100644 --- a/src/main/runtime/workspaces/session-hosts/get-runtime-id.ts +++ b/src/main/runtime/workspaces/session-hosts/get-runtime-id.ts @@ -1,8 +1,9 @@ import { crewWorkspaceNames } from '../../orchestration/crew/crew-workspace-names' import { isDeepStrictEqual } from 'node:util' -import type { ExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import { readStructuredAttentionWorkspaceLabels } from '../../agent-session/structured-agent-session-mobile-attention' import { TerminalListHostScope } from '../../terminal/query/terminal-list-host-scope' export abstract class OrcaRuntimeWithGetRuntimeId extends TerminalListHostScope { @@ -50,20 +51,23 @@ export abstract class OrcaRuntimeWithGetRuntimeId extends TerminalListHostScope } } - /** Every saved sleeping-agent record, for an explicit lookup by conversation (never per hook). */ - listSleepingAgentSessionRecords(): SleepingAgentSessionRecord[] { - const sessions = new Set() - for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { - const session = this.workspaceSessions.get(worktreeId) - if (session) { - sessions.add(session) - } - } - return [...sessions].flatMap((session) => - Object.values(session.sleepingAgentSessionsByPaneKey ?? {}) + /** This host's own saved sleeping-agent records, folder workspaces included: terminal agent + * ids name conversations on this host only, so no other partition or workspace is visited. */ + readLocalSleepingAgentSessions(): Readonly> { + return ( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.sleepingAgentSessionsByPaneKey ?? + {} ) } + /** One workspace's display name by id, without listing every workspace. */ + getWorkspaceDisplayName(worktreeId: string): string | null { + return this.store + ? (readStructuredAttentionWorkspaceLabels(this.store, { workspaceId: worktreeId }) + .worktreeLabel ?? null) + : null + } + protected getKnownWorkspaceSessionWorktreeIds(): Set { return this.workspaceSessions.getKnownWorktreeIds() } diff --git a/src/shared/agent-conversations/agent-conversation-contracts.ts b/src/shared/agent-conversations/agent-conversation-contracts.ts index 0db77310741..f8579754566 100644 --- a/src/shared/agent-conversations/agent-conversation-contracts.ts +++ b/src/shared/agent-conversations/agent-conversation-contracts.ts @@ -30,13 +30,17 @@ export type AgentListResult = { export type AgentShowResult = { orcaSessionId: string kind: AgentConversationKind - /** Where it is open now: none when it isn't open, two for one conversation resumed in two panes. */ + /** Where it is open now: none when it isn't open, two for one conversation resumed in two panes. + * Read from the conversation's own status rows, so they carry no title (`list` does). */ places: AgentConversationRow[] } +export type AgentReadTool = { name: string; state?: string } + export type AgentReadEntry = - | { kind: 'message'; role: string; text: string } - | { kind: 'tool'; name: string; state?: string } + /** `tools`: the tool calls a transcript message made, folded into its one entry. */ + | { kind: 'message'; role: string; text: string; tools?: AgentReadTool[] } + | ({ kind: 'tool' } & AgentReadTool) | { kind: 'approval' | 'question'; text: string; pending: boolean } | { kind: 'error'; text: string }