mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Show messages between agents as cards with the other agent's status (#34824)
* Snapshot agent message relations and verify task display prefixes * Show native chat agent messages in navigable cards * Keep agent message cards derived from canonical input and sidebar state * test: correct agent card contracts and cover raw queue editing * test: repair agent card runtime and operation contracts * fix: preserve queued image previews and typed test measurements * fix: use checked worker fixture and owned sender button typography * fix: complete agent card fixtures and native presentation * test: preserve queued edit exports and current authorship * test: use namespace type imports in queued edit fixture * Preserve sender progress and consolidate card contracts * Simplify sender and notice contract instrumentation * Remove duplicate sender and notice measurement mirrors * Use a template literal for the dispatch boundary fixture * Correct sender and notice verification phase accounting * Consolidate agent message tests around user contracts * Restore folded agent message preview on focus exit * Consolidate agent message regression coverage * Consolidate agent message fixture data * Fix display text test lint diagnostics * Use conversation view type for mail test records * Reuse active copy control in card workflow test
This commit is contained in:
@@ -20,7 +20,13 @@ describe('who another agent’s message is from, on mobile', () => {
|
||||
})
|
||||
|
||||
it('names each sender, an unnamed one as an agent, and counts the rest', () => {
|
||||
expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder')
|
||||
const attributed = from(['Coder'])
|
||||
expect(
|
||||
agentMessageAttribution('Message from', {
|
||||
...attributed,
|
||||
senders: attributed.senders.map((sender) => ({ ...sender, relation: 'worker' }))
|
||||
})
|
||||
).toBe('Message from Coder')
|
||||
expect(agentMessageAttribution('From', from([]))).toBe('From an agent')
|
||||
expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1')
|
||||
})
|
||||
|
||||
@@ -70,7 +70,10 @@ export function mobileQueuedMessageCards(
|
||||
...card,
|
||||
line: queuedMessageCardLine({
|
||||
...card,
|
||||
text: stripSentImagePromptMarkers(card.text, card.images?.length ?? 0)
|
||||
displayText: stripSentImagePromptMarkers(
|
||||
card.displayText ?? card.text,
|
||||
card.images?.length ?? 0
|
||||
)
|
||||
}),
|
||||
needsAttention: queuedMessageCardNeedsAttention(card),
|
||||
caption: queuedMessageCardCaption(card, facts.agentName, facts.statedFailures ?? []),
|
||||
|
||||
@@ -58,6 +58,74 @@ describe('mobile in-place queued Edit', () => {
|
||||
expect(state.hook!.queued.edit).toEqual(expect.any(Function))
|
||||
})
|
||||
|
||||
it('opens full canonical task input and publishes a changed Save as user-authored raw input', async () => {
|
||||
const prefix = 'You are working inside Orca, a multi-agent IDE.\n=== TASK ===\n'
|
||||
const message = queuedDraft({
|
||||
messageId: 'task',
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: `${prefix} Task 🐋 [Image #1] ` },
|
||||
{ type: 'image-ref', path: '/host/shot.png' }
|
||||
],
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [
|
||||
{
|
||||
party: { address: 'term_coord', terminalHandle: 'term_coord', orcaSessionId: null },
|
||||
name: 'Coordinator',
|
||||
relation: 'coordinator'
|
||||
}
|
||||
],
|
||||
orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' }
|
||||
}
|
||||
}
|
||||
})
|
||||
await mount(message)
|
||||
expect(state.hook!.queued.cards[0]).toMatchObject({
|
||||
line: ' Task 🐋',
|
||||
attribution: 'From Coordinator'
|
||||
})
|
||||
expect(state.hook!.queued.cards[0].images).toHaveLength(1)
|
||||
expect(state.hook!.queued.editor!.text).toBe(`${prefix} Task 🐋 [Image #1] `)
|
||||
await act(async () => {
|
||||
expect(await state.hook!.queued.editor!.save()).toBe(true)
|
||||
})
|
||||
expect(
|
||||
sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate')
|
||||
).toBe(false)
|
||||
expect(state.hook!.queued.cards[0]).toMatchObject({
|
||||
line: ' Task 🐋',
|
||||
attribution: 'From Coordinator'
|
||||
})
|
||||
await act(async () => {
|
||||
await state.hook!.queued.edit!('task')
|
||||
})
|
||||
act(() => state.hook!.queued.editor!.change(`${prefix}Edited`))
|
||||
await act(async () => {
|
||||
expect(await state.hook!.queued.editor!.save()).toBe(true)
|
||||
})
|
||||
const update = sendRequest.mock.calls.find(
|
||||
([method]) => method === 'agentSession.queuedMessageUpdate'
|
||||
)
|
||||
expect(fieldsOf(update?.[1]).text).toBe(`${prefix}Edited`)
|
||||
// The current host commits changed content without the original agent attribution.
|
||||
const { from: _sender, ...userBody } = withText(message.body, `${prefix}Edited`)
|
||||
const saved: typeof message = { ...message, body: userBody }
|
||||
act(() => state.listener?.(batchEvent([saved])))
|
||||
const [savedCard] = state.hook!.queued.cards
|
||||
expect(savedCard).toMatchObject({
|
||||
line: `${prefix}Edited`,
|
||||
attribution: null
|
||||
})
|
||||
await act(async () => {
|
||||
await state.hook!.queued.edit!('task')
|
||||
})
|
||||
expect(state.hook!.queued.editor!.text).toBe(`${prefix}Edited`)
|
||||
await act(async () => state.hook!.queued.editor!.cancel())
|
||||
})
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, false],
|
||||
|
||||
+9
-1
@@ -19,7 +19,7 @@ import {
|
||||
} from './structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_SESSION as DEFAULT_SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
@@ -52,6 +52,7 @@ export type QueuedMessageTestRig = Awaited<ReturnType<typeof createQueuedMessage
|
||||
|
||||
export async function createQueuedMessageTestRig(
|
||||
options: QueuedRigProviderOptions & {
|
||||
sessionId?: string
|
||||
idleSweep?: { idleMs: number; intervalMs: number }
|
||||
now?: () => number
|
||||
/** Teardown records its restart offers, which `restartOffers` lists. */
|
||||
@@ -61,6 +62,7 @@ export async function createQueuedMessageTestRig(
|
||||
probeOwner?: StructuredAgentSessionHostDeps['probeOwner']
|
||||
} = {}
|
||||
) {
|
||||
const SESSION = options.sessionId ?? DEFAULT_SESSION
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-'))
|
||||
resetHostTestOperationIds()
|
||||
const store = await openTestAgentSessionRecordStore(root)
|
||||
@@ -83,6 +85,12 @@ export async function createQueuedMessageTestRig(
|
||||
let host = makeHost()
|
||||
const firstAttach = hostTestAttachParams(null, {
|
||||
...(options.location ? { location: options.location } : {}),
|
||||
envelope: {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
...(options.agent === 'claude'
|
||||
? ({
|
||||
provider: 'claude',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AgentMessageSender } from '../../../shared/agent-session/agent-session-message-source'
|
||||
import { readWorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope'
|
||||
import type { OrchestrationDb, DispatchContextRow } from './db'
|
||||
import { canonicalOrcaSessionId } from './canonical-orca-session-id'
|
||||
import { resolveOrchestrationParty } from './orchestration-party'
|
||||
import { runCoordinatorKey } from './orchestration-caller-identity'
|
||||
|
||||
type Party = Pick<AgentMessageSender['party'], 'terminalHandle' | 'orcaSessionId'>
|
||||
|
||||
function sameParty(a: Party, b: Party): boolean {
|
||||
if (a.orcaSessionId && b.orcaSessionId) {
|
||||
return canonicalOrcaSessionId(a.orcaSessionId) === canonicalOrcaSessionId(b.orcaSessionId)
|
||||
}
|
||||
return Boolean(a.terminalHandle && a.terminalHandle === b.terminalHandle)
|
||||
}
|
||||
|
||||
function localDispatch(dispatch: DispatchContextRow): boolean {
|
||||
const scope = readWorkerTerminalHostScope(dispatch.host_scope)
|
||||
return scope.kind === 'absent' || (scope.kind === 'local' && scope.id === 'local')
|
||||
}
|
||||
|
||||
/** Delivery-time evidence only; missing, ambiguous or foreign lineage is unknown. */
|
||||
export function agentMessageRelation(args: {
|
||||
db: OrchestrationDb | null
|
||||
sender: AgentMessageSender['party']
|
||||
recipient: string
|
||||
runId: string
|
||||
reportedDispatchId?: string
|
||||
recipientDispatchId: string | null
|
||||
}): AgentMessageSender['relation'] {
|
||||
try {
|
||||
const { db, sender, runId } = args
|
||||
if (!db || sender.address.startsWith('dispatch:') || args.recipient.startsWith('dispatch:')) {
|
||||
return undefined
|
||||
}
|
||||
const run = db.getRun(runId)
|
||||
if (!run) {
|
||||
return undefined
|
||||
}
|
||||
const recipient =
|
||||
args.recipient === `run:${runId}`
|
||||
? runCoordinatorKey(run)
|
||||
: resolveOrchestrationParty(args.recipient, db)
|
||||
const coordinator = runCoordinatorKey(run)
|
||||
const dispatchFor = (party: Party, id?: string | null) => {
|
||||
const candidates = id
|
||||
? [db.getDispatchContextById(id)]
|
||||
: db.getActiveDispatchMailboxOwners(
|
||||
party.terminalHandle ??
|
||||
(party.orcaSessionId ? `orca_session_id:${party.orcaSessionId}` : '')
|
||||
)
|
||||
const matches = candidates.filter((row): row is DispatchContextRow =>
|
||||
Boolean(
|
||||
row &&
|
||||
row.run_id === runId &&
|
||||
localDispatch(row) &&
|
||||
sameParty(party, {
|
||||
terminalHandle: row.assignee_handle,
|
||||
orcaSessionId: row.assignee_orca_session_id
|
||||
})
|
||||
)
|
||||
)
|
||||
return matches.length === 1 ? matches[0] : undefined
|
||||
}
|
||||
const parentOf = (dispatch: DispatchContextRow | undefined): Party | undefined => {
|
||||
if (!dispatch) {
|
||||
return undefined
|
||||
}
|
||||
if (dispatch.creator_handle || dispatch.creator_orca_session_id) {
|
||||
return {
|
||||
terminalHandle: dispatch.creator_handle,
|
||||
orcaSessionId: dispatch.creator_orca_session_id
|
||||
}
|
||||
}
|
||||
if (dispatch.creator_dispatch_id) {
|
||||
const creator = db.getDispatchContextById(dispatch.creator_dispatch_id)
|
||||
return creator && creator.run_id === runId && localDispatch(creator)
|
||||
? {
|
||||
terminalHandle: creator.assignee_handle,
|
||||
orcaSessionId: creator.assignee_orca_session_id
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const senderDispatch = dispatchFor(sender, args.reportedDispatchId)
|
||||
const recipientDispatch = dispatchFor(recipient, args.recipientDispatchId)
|
||||
const senderParent = parentOf(senderDispatch)
|
||||
const recipientParent = parentOf(recipientDispatch)
|
||||
if (!sameParty(sender, recipient)) {
|
||||
if (senderParent && sameParty(senderParent, recipient)) {
|
||||
return 'worker'
|
||||
}
|
||||
if (recipientParent && sameParty(recipientParent, sender)) {
|
||||
return 'coordinator'
|
||||
}
|
||||
}
|
||||
return (senderParent || sameParty(sender, coordinator)) &&
|
||||
(recipientParent || sameParty(recipient, coordinator))
|
||||
? 'none'
|
||||
: undefined
|
||||
} catch {
|
||||
// Lineage is informational; an unavailable database must never block delivery.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ export function dispatchTaskSource(args: {
|
||||
const { dispatch } = args
|
||||
return {
|
||||
kind: 'agent',
|
||||
senders: args.from ? [agentMessageSender(args.from, args.db, args.senderName)] : [],
|
||||
senders: args.from
|
||||
? [{ ...agentMessageSender(args.from, args.db, args.senderName), relation: 'coordinator' }]
|
||||
: [],
|
||||
orchestration: {
|
||||
message: 'task',
|
||||
runId: dispatch.run_id,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { structuredMailSource } from './structured-mail-source'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readAgentMessageSource } from '../../../shared/agent-session/agent-session-message-source'
|
||||
import { agentMessageHeading } from '../../../shared/agent-session/agent-message-presentation'
|
||||
import {
|
||||
agentSessionConversationView,
|
||||
type AgentSessionConversationView
|
||||
} from '../../../shared/agent-session-conversation-view'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../../shared/agent-session-record.test-fixture'
|
||||
import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture'
|
||||
import { orchestrationSenderName } from './orchestration-sender-name'
|
||||
import * as relations from './agent-message-relation'
|
||||
import * as lineage from './structured-session-lineage'
|
||||
import { structuredMailSource, type MailSourceMessage } from './structured-mail-source'
|
||||
|
||||
const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
|
||||
|
||||
@@ -70,3 +85,218 @@ describe('who delivered mail is from', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('snapshots immediate relations across completion/reload and keeps unknown evidence non-gating', () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
try {
|
||||
const run = db.createRun({
|
||||
objective: 'cards',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: null
|
||||
})
|
||||
const assign = (handle: string, creator: string) =>
|
||||
db.createDispatchContext({
|
||||
taskId: db.createTask({ runId: run.id, spec: handle }).id,
|
||||
assigneeHandle: handle,
|
||||
creator: { kind: 'terminal', handle: creator },
|
||||
maxDepth: 8
|
||||
})
|
||||
const worker = assign('term_worker', 'term_coord')
|
||||
assign('term_peer', 'term_coord')
|
||||
const child = assign('term_child', 'term_worker')
|
||||
const mail = (
|
||||
recipient: string,
|
||||
addresses: string[],
|
||||
dispatchId: string | null = null,
|
||||
report?: string
|
||||
) =>
|
||||
structuredMailSource({
|
||||
db,
|
||||
mailboxHandle: recipient,
|
||||
dispatchId,
|
||||
senderName: () => 'Sender',
|
||||
batch: addresses.map<MailSourceMessage>((address, i) => ({
|
||||
id: `m${i}`,
|
||||
from_handle: address,
|
||||
run_id: run.id,
|
||||
type: report ? 'worker_done' : 'status',
|
||||
payload: report ? JSON.stringify({ dispatchId: report }) : null
|
||||
}))
|
||||
})
|
||||
expect(mail('term_coord', ['term_worker']).senders[0].relation).toBe('worker')
|
||||
expect(agentMessageHeading(mail('term_coord', ['term_worker']))).toBe('worker')
|
||||
expect(mail('term_worker', ['term_coord'], worker.id).senders[0].relation).toBe('coordinator')
|
||||
expect(agentMessageHeading(mail('term_worker', ['term_coord'], worker.id))).toBe('coordinator')
|
||||
expect(mail('term_coord', ['term_child']).senders[0].relation).toBe('none')
|
||||
const delivered = mail('term_worker', ['term_child', 'term_peer'], worker.id)
|
||||
db.db.prepare("UPDATE dispatch_contexts SET status = 'completed' WHERE id = ?").run(child.id)
|
||||
const reload = readAgentMessageSource(JSON.parse(JSON.stringify(delivered)))!
|
||||
expect(reload.senders.map((sender) => sender.relation)).toEqual(['worker', 'none'])
|
||||
expect(agentMessageHeading(reload)).toBe('generic')
|
||||
expect(mail('term_worker', ['term_child'], worker.id).senders[0].relation).toBeUndefined()
|
||||
expect(mail('term_worker', ['term_child'], worker.id, child.id).senders[0].relation).toBe(
|
||||
'worker'
|
||||
)
|
||||
expect(mail('term_coord', ['unknown']).senders[0].relation).toBeUndefined()
|
||||
db.db
|
||||
.prepare('UPDATE dispatch_contexts SET creator_handle = NULL, depth = 1 WHERE id = ?')
|
||||
.run(worker.id)
|
||||
expect(mail('term_coord', ['term_worker']).senders[0].relation).toBeUndefined()
|
||||
db.db
|
||||
.prepare('UPDATE dispatch_contexts SET creator_handle = ? WHERE id = ?')
|
||||
.run('term_coord', worker.id)
|
||||
const good: MailSourceMessage = {
|
||||
id: 'good',
|
||||
from_handle: 'term_worker',
|
||||
run_id: run.id,
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ dispatchId: worker.id })
|
||||
}
|
||||
for (const bad of [
|
||||
{ ...good, id: 'foreign', run_id: 'missing' },
|
||||
{ ...good, id: 'wrong-report', payload: JSON.stringify({ dispatchId: child.id }) },
|
||||
{ ...good, id: 'invalid', payload: '{' }
|
||||
]) {
|
||||
const source = structuredMailSource({
|
||||
db,
|
||||
mailboxHandle: 'term_coord',
|
||||
dispatchId: null,
|
||||
batch: [good, bad],
|
||||
senderName: () => 'Sender'
|
||||
})
|
||||
expect(source.senders[0].relation).toBeUndefined()
|
||||
expect(source.orchestration).toMatchObject({
|
||||
messages: [{ messageId: 'good' }, { messageId: bad.id }]
|
||||
})
|
||||
}
|
||||
db.db
|
||||
.prepare('UPDATE dispatch_contexts SET host_scope = ? WHERE id = ?')
|
||||
.run(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' }), worker.id)
|
||||
expect(mail('term_coord', ['term_worker']).senders[0].relation).toBeUndefined()
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([100, 1000])(
|
||||
'bounds real canonical record work for repeated/distinct/unknown notices with %i workers',
|
||||
(n) => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const coordinator = testOrcaSessionId('coordinator')
|
||||
const sessions = Array.from({ length: n }, (_, i) => testOrcaSessionId(`worker-${i}`))
|
||||
const base = agentSessionConversationView(
|
||||
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: coordinator }))
|
||||
)
|
||||
const successor = testOrcaSessionId('cleared-worker')
|
||||
const records: AgentSessionConversationView[] = sessions.map((sessionId, i) => ({
|
||||
...base,
|
||||
sessionId,
|
||||
conversationName: `Worker ${i}`
|
||||
}))
|
||||
records[0] = {
|
||||
...records[0],
|
||||
conversationCommand: {
|
||||
command: 'clear',
|
||||
state: 'completed',
|
||||
phase: 'committed',
|
||||
operationId: 'clear',
|
||||
callerKey: 'caller',
|
||||
replacementSessionId: successor
|
||||
}
|
||||
}
|
||||
records.push(
|
||||
{ ...base, conversationName: 'Coordinator' },
|
||||
{ ...base, sessionId: successor, conversationName: 'Worker 0' }
|
||||
)
|
||||
const bySession = new Map(records.map((record) => [record.sessionId, record]))
|
||||
let visits = 0
|
||||
records.forEach((record, i) =>
|
||||
Object.defineProperty(records, i, {
|
||||
get() {
|
||||
visits++
|
||||
return record
|
||||
}
|
||||
})
|
||||
)
|
||||
const reader = {
|
||||
listRecords: () => records,
|
||||
getRecord: (id: string) => bySession.get(id) ?? null
|
||||
}
|
||||
vi.spyOn(lineage, 'readAgentSessionRecordStore').mockReturnValue(reader)
|
||||
try {
|
||||
const run = db.createRun({
|
||||
objective: 'cost',
|
||||
coordinatorHandle: null,
|
||||
coordinatorPaneKey: null,
|
||||
coordinatorOrcaSessionId: coordinator
|
||||
})
|
||||
const dispatches = sessions.slice(0, 10).map((session) =>
|
||||
db.createDispatchContext({
|
||||
taskId: db.createTask({ runId: run.id, spec: 'work' }).id,
|
||||
assigneeHandle: `orca_session_id:${session}`,
|
||||
creator: { kind: 'session', orcaSessionId: coordinator },
|
||||
maxDepth: 8
|
||||
})
|
||||
)
|
||||
for (const evidence of ['repeated', 'distinct', 'unknown'] as const) {
|
||||
const distinct = evidence === 'distinct'
|
||||
const relation = vi.spyOn(relations, 'agentMessageRelation')
|
||||
relation.mockClear()
|
||||
const batch: MailSourceMessage[] = Array.from({ length: n }, (_, i) => ({
|
||||
id: `m${i}`,
|
||||
from_handle: `orca_session_id:${!distinct || i % 10 === 0 ? successor : sessions[i % 10]}`,
|
||||
run_id: evidence === 'unknown' ? 'missing' : run.id,
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ dispatchId: dispatches[distinct ? i % 10 : 0].id })
|
||||
}))
|
||||
let batchReads = 0
|
||||
batch.forEach((message, i) =>
|
||||
Object.defineProperty(batch, i, {
|
||||
get() {
|
||||
batchReads++
|
||||
return message
|
||||
}
|
||||
})
|
||||
)
|
||||
visits = 0
|
||||
const source = structuredMailSource({
|
||||
db,
|
||||
mailboxHandle: `orca_session_id:${coordinator}`,
|
||||
dispatchId: null,
|
||||
batch,
|
||||
senderName: (party, report) =>
|
||||
orchestrationSenderName(
|
||||
party,
|
||||
{
|
||||
db,
|
||||
records: reader,
|
||||
chatTab: () => null,
|
||||
terminal: () => null,
|
||||
generatedTitlesEnabled: true
|
||||
},
|
||||
report
|
||||
)
|
||||
})
|
||||
const groups = distinct ? 10 : 1
|
||||
// Known groups walk canonical records eight times; a missing Run ends after sender canonicalization.
|
||||
expect(visits).toBe((evidence === 'unknown' ? 1 : 8 * groups) * records.length)
|
||||
expect(relation).toHaveBeenCalledTimes(groups)
|
||||
expect(batchReads).toBe(2 * n)
|
||||
expect(source.senders[0].party.orcaSessionId).toBe(sessions[0])
|
||||
expect(source.senders.map((sender) => [sender.name, sender.relation])).toEqual(
|
||||
Array.from({ length: groups }, (_, i) => [
|
||||
`Worker ${i}`,
|
||||
evidence === 'unknown' ? undefined : 'worker'
|
||||
])
|
||||
)
|
||||
expect(
|
||||
source.orchestration?.message === 'mail-notice' && source.orchestration.messages
|
||||
).toHaveLength(n)
|
||||
}
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../../../shared/agent-session/agent-session-message-source'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { MessageRow, OrchestrationDb } from './db'
|
||||
import { agentMessageRelation } from './agent-message-relation'
|
||||
import { agentMessageSender, type SenderNameResolver } from './agent-message-sender'
|
||||
import type { OrchestrationCliCommand } from './cli-command'
|
||||
import { formatMessagePointer } from './formatter'
|
||||
@@ -23,23 +24,54 @@ export function structuredMailSource(input: {
|
||||
batch: readonly MailSourceMessage[]
|
||||
senderName: SenderNameResolver
|
||||
}): AgentMessageSource {
|
||||
const senders = new Map<string, AgentMessageSender>()
|
||||
for (const { from_handle: address } of input.batch) {
|
||||
if (!senders.has(address)) {
|
||||
senders.set(
|
||||
address,
|
||||
agentMessageSender(
|
||||
address,
|
||||
input.db,
|
||||
input.senderName,
|
||||
reportedDispatchId(input.batch, address)
|
||||
)
|
||||
)
|
||||
const groups = new Map<string, { message: MailSourceMessage; report: ReportFact }[]>()
|
||||
for (const message of input.batch) {
|
||||
let group = groups.get(message.from_handle)
|
||||
if (!group) {
|
||||
group = []
|
||||
groups.set(message.from_handle, group)
|
||||
}
|
||||
group.push({ message, report: reportFact(message) })
|
||||
}
|
||||
const senders: AgentMessageSender[] = []
|
||||
for (const [address, group] of groups) {
|
||||
const reported = group.find(({ report }) => report.kind === 'valid')?.report
|
||||
const sender = agentMessageSender(
|
||||
address,
|
||||
input.db,
|
||||
input.senderName,
|
||||
reported?.kind === 'valid' ? reported.dispatchId : undefined
|
||||
)
|
||||
const relations = new Map<string, AgentMessageSender['relation']>()
|
||||
const values = group.map(({ message, report }) => {
|
||||
// Sender and DB are fixed by this group/invocation; every varying evidence field is keyed.
|
||||
const key = JSON.stringify([input.mailboxHandle, input.dispatchId, message.run_id, report])
|
||||
if (!relations.has(key)) {
|
||||
relations.set(
|
||||
key,
|
||||
report.kind === 'invalid'
|
||||
? undefined
|
||||
: agentMessageRelation({
|
||||
db: input.db,
|
||||
sender: sender.party,
|
||||
recipient: input.mailboxHandle,
|
||||
runId: message.run_id,
|
||||
reportedDispatchId: report.kind === 'valid' ? report.dispatchId : undefined,
|
||||
recipientDispatchId: input.dispatchId
|
||||
})
|
||||
)
|
||||
}
|
||||
return relations.get(key)
|
||||
})
|
||||
const relation = values[0]
|
||||
senders.push({
|
||||
...sender,
|
||||
...(relation && values.every((value) => value === relation) ? { relation } : {})
|
||||
})
|
||||
}
|
||||
return {
|
||||
kind: 'agent',
|
||||
senders: [...senders.values()],
|
||||
senders,
|
||||
orchestration: {
|
||||
message: 'mail-notice',
|
||||
mailbox: input.mailboxHandle,
|
||||
@@ -77,28 +109,27 @@ export function structuredMailNotice(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/** The dispatch a sender's own `worker_done` here reports: the task it just finished, whose
|
||||
* dispatch that report already settled. */
|
||||
function reportedDispatchId(
|
||||
batch: readonly MailSourceMessage[],
|
||||
address: string
|
||||
): string | undefined {
|
||||
for (const message of batch) {
|
||||
if (message.from_handle !== address || message.type !== 'worker_done' || !message.payload) {
|
||||
continue
|
||||
}
|
||||
type ReportFact = { kind: 'mail' } | { kind: 'invalid' } | { kind: 'valid'; dispatchId: string }
|
||||
|
||||
function reportFact(message: MailSourceMessage): ReportFact {
|
||||
if (message.type !== 'worker_done') {
|
||||
return { kind: 'mail' }
|
||||
}
|
||||
if (message.payload) {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(message.payload)
|
||||
const dispatchId =
|
||||
typeof payload === 'object' && payload !== null && 'dispatchId' in payload
|
||||
? payload.dispatchId
|
||||
: undefined
|
||||
if (typeof dispatchId === 'string' && dispatchId.length > 0) {
|
||||
return dispatchId
|
||||
if (
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'dispatchId' in payload &&
|
||||
typeof payload.dispatchId === 'string' &&
|
||||
payload.dispatchId.length > 0
|
||||
) {
|
||||
return { kind: 'valid', dispatchId: payload.dispatchId }
|
||||
}
|
||||
} catch {
|
||||
// A payload that does not parse names no dispatch.
|
||||
// Invalid evidence stays unknown; delivery and the other messages remain intact.
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const IDENTITY: StructuredWorkerIdentity = {
|
||||
|
||||
function harness(options: {
|
||||
/** False: the session cannot be read (not attached). */
|
||||
relationReadFails?: boolean
|
||||
attached?: boolean
|
||||
dispatchState?: 'accepted' | 'rejected' | 'unknown'
|
||||
/** The chat was busy: its queue holds the pointer as a card. */
|
||||
@@ -62,7 +63,14 @@ function harness(options: {
|
||||
() => undefined
|
||||
)
|
||||
const stored = new Map<string, StructuredPointerOperationRow>()
|
||||
const relationRead = vi.fn(() => {
|
||||
if (options.relationReadFails) {
|
||||
throw new Error('lineage unavailable')
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const db = {
|
||||
getRun: relationRead,
|
||||
getDispatchContextById: () => ({ run_id: 'run_1' }),
|
||||
getOutstandingMailboxDelivery: (handle: string) =>
|
||||
((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) ||
|
||||
@@ -104,6 +112,7 @@ function harness(options: {
|
||||
})
|
||||
return {
|
||||
delivery,
|
||||
relationRead,
|
||||
markAsDelivered,
|
||||
send: sendMock,
|
||||
readContext,
|
||||
@@ -232,19 +241,30 @@ describe('structured mailbox pointer delivery', () => {
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1')
|
||||
})
|
||||
|
||||
it('rechecks a held notice when the answer edge lands during its host call', async () => {
|
||||
const { delivery, send, markAsDelivered, setAwaitingHuman } = harness({})
|
||||
setAwaitingHuman(true)
|
||||
send.mockImplementationOnce(async () => {
|
||||
setAwaitingHuman(false)
|
||||
delivery.onJournalActivity('session-1')
|
||||
return { kind: 'held' }
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1')
|
||||
})
|
||||
it.each([false, true])(
|
||||
'rechecks the original dispatch and direct failed-relation held notice (failure=%s)',
|
||||
async (relationReadFails) => {
|
||||
const { delivery, send, markAsDelivered, setAwaitingHuman, relationRead } = harness({
|
||||
relationReadFails,
|
||||
...(relationReadFails ? { mailbox: 'term_worker' } : {})
|
||||
})
|
||||
setAwaitingHuman(true)
|
||||
send.mockImplementationOnce(async () => {
|
||||
setAwaitingHuman(false)
|
||||
delivery.onJournalActivity('session-1')
|
||||
return { kind: 'held' }
|
||||
})
|
||||
delivery.deliverForHandle(relationReadFails ? 'term_worker' : 'dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
expect(relationRead).toHaveBeenCalledTimes(relationReadFails ? 2 : 0)
|
||||
expect(send.mock.calls[1][0].body.from?.senders[0].relation).toBeUndefined()
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(
|
||||
['m1'],
|
||||
relationReadFails ? 'term_worker' : 'dispatch:d1'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('sends the pointer through the chat, with who it is from, and counts it pointed once queued', async () => {
|
||||
const { delivery, send, markAsDelivered, stored } = harness({ queued: true })
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationRpcHarness } from './rpc-test-harness'
|
||||
import { resolveOrchestrationSessionCaller } from '../../orchestration-session-caller'
|
||||
import { setStructuredAgentSessionHost } from '../../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from '../../../../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
hostTestAttachParams,
|
||||
hostTestOperationId
|
||||
} from '../../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { testOrcaSessionId } from '../../../../../shared/orca-session-address-test-fixture'
|
||||
import { agentMessageDisplayText } from '../../../../../shared/agent-session/agent-message-display-text'
|
||||
import { readAgentMessageSource } from '../../../../../shared/agent-session/agent-session-message-source'
|
||||
import { nativeChatMessageCopyText } from '../../../../../shared/native-chat-message-copy-text'
|
||||
import {
|
||||
projectQueuedMessageCards,
|
||||
queuedMessageCardLine
|
||||
} from '../../../../../shared/structured-agent-session-queued-cards'
|
||||
import { projectStructuredAgentSessionStatusSummary } from '../../../../../shared/structured-agent-session-projection'
|
||||
import { latestStructuredAgentSessionPrompt } from '../../../../../shared/structured-agent-session-latest-request'
|
||||
import { isOrcaDispatchPrompt } from '../../../../../shared/agent-row-primary-text'
|
||||
import * as placement from './worker/worker-start-agent-placement'
|
||||
import * as topology from './worker/worker-topology'
|
||||
|
||||
const h = createOrchestrationRpcHarness()
|
||||
let rig: QueuedMessageTestRig | undefined
|
||||
const COORD = testOrcaSessionId('command-coordinator')
|
||||
const WORKER = testOrcaSessionId('command-worker')
|
||||
afterEach(async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rig?.dispose()
|
||||
rig = undefined
|
||||
h.cleanup()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
vi.doUnmock('../../../../../cli/runtime-client')
|
||||
vi.doUnmock('../../../../../cli/format')
|
||||
vi.resetModules()
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
it.each(['worker-start', 'dispatch'] as const)(
|
||||
'real %s argv constructs, persists and delivers canonical input with a derived task display',
|
||||
async (command) => {
|
||||
rig = await createQueuedMessageTestRig({ sessionId: WORKER })
|
||||
const current = rig
|
||||
setStructuredAgentSessionHost(current.host)
|
||||
const attach = hostTestAttachParams(null, {
|
||||
envelope: {
|
||||
sessionId: COORD,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
}
|
||||
})
|
||||
expect(await current.host.attach(QUEUED_RIG_CALLER, attach)).toMatchObject({ ok: true })
|
||||
const { db, runtime } = h.setup(false)
|
||||
vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue()
|
||||
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
|
||||
type WorkspaceLookup = {
|
||||
showManagedTerminalWorkspace: (
|
||||
...args: Parameters<typeof runtime.showManagedTerminalWorkspace>
|
||||
) => Promise<Awaited<ReturnType<typeof placement.placeWorkerAgent>>['worktree']>
|
||||
}
|
||||
vi.spyOn<WorkspaceLookup, 'showManagedTerminalWorkspace'>(
|
||||
runtime,
|
||||
'showManagedTerminalWorkspace'
|
||||
).mockResolvedValue({
|
||||
id: 'workspace-1',
|
||||
repoId: 'repo'
|
||||
})
|
||||
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
|
||||
supported: true
|
||||
})
|
||||
vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
terminalHandle: 'term_worker',
|
||||
ptyId: 'pty',
|
||||
worktreeId: 'workspace-1',
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
processIncarnation: 'fixture:1',
|
||||
launchTokenHash: null,
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
// Placement/provider are external fixtures; admission, constructors, host, journal, queue and settlement run in production.
|
||||
vi.spyOn(placement, 'placeWorkerAgent').mockImplementation(async (args) => ({
|
||||
mode: args.mode,
|
||||
worktree: { id: 'workspace-1', repoId: 'repo' },
|
||||
terminalHandle: 'term_worker',
|
||||
structuredSession: {
|
||||
host: current.host,
|
||||
identity: {
|
||||
sessionId: WORKER,
|
||||
handle: 'term_worker',
|
||||
agent: args.agent ?? null,
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
processIncarnation: 'fixture:1',
|
||||
worktreeId: 'workspace-1',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
},
|
||||
fence: current.store.requireWritableRecord(WORKER).lease.runtimeFence
|
||||
},
|
||||
setupReceipt: placement.EXISTING_WORKTREE_SETUP
|
||||
}))
|
||||
vi.spyOn(topology, 'monitorWorkerSetup').mockImplementation(() => {})
|
||||
const run = db.createRun({
|
||||
objective: 'cards',
|
||||
coordinatorHandle: null,
|
||||
coordinatorPaneKey: null,
|
||||
coordinatorOrcaSessionId: COORD
|
||||
})
|
||||
vi.stubEnv('ORCA_AGENT_SESSION_ID', COORD)
|
||||
vi.stubEnv('ORCA_TERMINAL_HANDLE', '')
|
||||
vi.stubEnv('ORCA_PAIRING_CODE', '')
|
||||
vi.stubEnv('ORCA_ENVIRONMENT', '')
|
||||
const requests: string[] = []
|
||||
vi.doMock('../../../../../cli/runtime-client', async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
RuntimeClient: function () {
|
||||
return {
|
||||
isRemote: false,
|
||||
async call(method: string, params: Record<string, unknown> = {}) {
|
||||
const admitted = await resolveOrchestrationSessionCaller(
|
||||
runtime,
|
||||
{
|
||||
id: 'cli',
|
||||
authToken: 'test-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationCompatibilityEvidence: { agentSessionId: COORD }
|
||||
},
|
||||
undefined
|
||||
)
|
||||
requests.push(method)
|
||||
const rpc = h.findMethod(method)
|
||||
const parsed = rpc.params?.parse(admitted.request.params)
|
||||
return {
|
||||
result: await rpc.handler(parsed, {
|
||||
runtime,
|
||||
orchestrationCaller: admitted.caller,
|
||||
orchestrationCompatibilityEvidence:
|
||||
admitted.request.orchestrationCompatibilityEvidence
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
const output = vi.fn()
|
||||
vi.doMock('../../../../../cli/format', () => ({ printResult: output }))
|
||||
const cliPath = '../../../../../cli/index'
|
||||
const { main }: { main(argv: string[]): Promise<void> } = await import(cliPath)
|
||||
const task = ' Current task 🐋\r\n\r\n=== TASK ===\nDo not clip the task. '
|
||||
let argv: string[]
|
||||
let openingSend: string | undefined
|
||||
if (command === 'dispatch') {
|
||||
openingSend = await current.workingSend()
|
||||
const created = db.createTask({ runId: run.id, spec: task })
|
||||
argv = [
|
||||
'orchestration',
|
||||
'dispatch',
|
||||
'--task',
|
||||
created.id,
|
||||
'--to',
|
||||
`orca_session_id:${WORKER}`,
|
||||
'--inject',
|
||||
'--return-preamble',
|
||||
'--json'
|
||||
]
|
||||
} else {
|
||||
argv = [
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--spec',
|
||||
task,
|
||||
'--agent',
|
||||
'codex',
|
||||
'--timeout-ms',
|
||||
'1000',
|
||||
'--json'
|
||||
]
|
||||
}
|
||||
const running = main(argv)
|
||||
if (command === 'worker-start') {
|
||||
await eventually(() => expect(current.dispatch).toHaveBeenCalled())
|
||||
const id = current.dispatch.mock.calls[0][0].clientMessageId
|
||||
await current.settleAccepted(id, 'task-turn')
|
||||
}
|
||||
await running
|
||||
expect(requests).toContain(
|
||||
command === 'dispatch' ? 'orchestration.dispatch' : 'orchestration.workerStart'
|
||||
)
|
||||
const snapshot = await current.host.journalSnapshot(WORKER)
|
||||
const history = await current.host.history({ sessionId: WORKER, direction: 'tail' })
|
||||
if (!history.ok) {
|
||||
throw new Error('cannot reload real history')
|
||||
}
|
||||
const queued = history.page.queuedMessages?.[0]
|
||||
if (command === 'dispatch') {
|
||||
expect(queued, 'busy --inject must enter the queue').toBeDefined()
|
||||
if (!queued) {
|
||||
throw new Error('busy dispatch bypassed queue')
|
||||
}
|
||||
} else {
|
||||
expect(queued, 'worker-start sends immediately').toBeUndefined()
|
||||
}
|
||||
const body =
|
||||
queued?.body ??
|
||||
snapshot.items.find((item) => item.body.kind === 'message' && item.body.from)?.body
|
||||
if (!body || body.kind !== 'message') {
|
||||
throw new Error('production host did not persist task')
|
||||
}
|
||||
const from = readAgentMessageSource(JSON.parse(JSON.stringify(body.from)))
|
||||
expect(from?.senders[0].party.orcaSessionId).toBe(COORD)
|
||||
expect(from?.senders[0].relation).toBe('coordinator')
|
||||
expect(agentMessageDisplayText(body.blocks, from)).toBe(task)
|
||||
const raw = nativeChatMessageCopyText(body)
|
||||
expect(raw).toContain('You are working inside Orca')
|
||||
expect(raw.endsWith(task)).toBe(true)
|
||||
const items = [{ itemId: 'task', sequence: 1, revision: 1, observedAt: 1, body }]
|
||||
expect(latestStructuredAgentSessionPrompt(items)).toBe(raw)
|
||||
const prompt = projectStructuredAgentSessionStatusSummary(items).latestPrompt
|
||||
expect(isOrcaDispatchPrompt(prompt)).toBe(true)
|
||||
expect(prompt).toContain('=== TASK === Current task')
|
||||
if (from?.orchestration?.message !== 'task') {
|
||||
throw new Error('task identity missing')
|
||||
}
|
||||
expect(prompt).toContain(`Your task ID is: ${from.orchestration.taskId}`)
|
||||
if (queued) {
|
||||
const [card] = projectQueuedMessageCards([queued], [], {})
|
||||
expect(queuedMessageCardLine(card)).toBe(task)
|
||||
expect(card.text).toBe(raw) // Exactly what the editor opens.
|
||||
const journal = current.host.collaboratorsForTests().sessions.get(WORKER)!.journal
|
||||
const edited = `${raw.slice(0, raw.length - task.length)}Edited task`
|
||||
const fields = {
|
||||
messageId: queued.messageId,
|
||||
expectedBodyFingerprint: journal.queuedMessages.get(queued.messageId)!.fingerprint,
|
||||
text: edited
|
||||
}
|
||||
expect(
|
||||
await current.host.queuedMessageUpdate(QUEUED_RIG_CALLER, {
|
||||
envelope: current.envelope(
|
||||
fields,
|
||||
'agentSession.queuedMessageUpdate',
|
||||
hostTestOperationId()
|
||||
),
|
||||
...fields
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
const page = await current.host.history({ sessionId: WORKER, direction: 'tail' })
|
||||
if (!page.ok) {
|
||||
throw new Error('cannot reopen queue')
|
||||
}
|
||||
const reopened = page.page.queuedMessages!.find((row) => row.messageId === queued.messageId)!
|
||||
expect(nativeChatMessageCopyText(reopened.body)).toBe(edited)
|
||||
expect(reopened.body).not.toHaveProperty('from')
|
||||
if (!openingSend) {
|
||||
throw new Error('busy dispatch has no opening send')
|
||||
}
|
||||
expect(await current.sendNow(queued.messageId)).toMatchObject({ ok: true })
|
||||
await current.settleAccepted(openingSend, 'opening-task-turn')
|
||||
await eventually(async () => {
|
||||
const handoff = await current.handoffId(queued.messageId)
|
||||
const delivered = current.dispatch.mock.calls.find(
|
||||
([args]) => args.clientMessageId === handoff
|
||||
)
|
||||
expect(delivered).toBeDefined()
|
||||
expect(nativeChatMessageCopyText(delivered![0].body)).toBe(edited)
|
||||
})
|
||||
} else {
|
||||
expect(nativeChatMessageCopyText(current.dispatch.mock.calls[0][0].body)).toBe(raw)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { makeTab } from '@/store/slices/store-test-helpers'
|
||||
import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source'
|
||||
import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard'
|
||||
import { NativeChatUserMessageRow } from './NativeChatUserMessageRow'
|
||||
|
||||
const workspace = 'agent-message-fixture'
|
||||
const image =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAAA8CAIAAADXHaAKAAAAvklEQVR4nO3SUQkAIBTAwBfUAAawP5YQhHFwAfaxWftAxnwvgIcMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSbm/mJH95bvMnAAAAABJRU5ErkJggg=='
|
||||
const prose =
|
||||
'First paragraph with **real Markdown**, a [web link](https://example.com) and inline `code`.\n\nSecond paragraph keeps its own spacing. This longer line wraps differently in the narrow frame, so Show more is based on rendered lines.\n\n\n\nLast paragraph after the image.'
|
||||
function cards() {
|
||||
const human: NativeChatMessage = {
|
||||
id: 'human',
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text: 'An ordinary human message.' }],
|
||||
timestamp: 1_800_000_000_000
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{(['task', 'coordinator', 'worker', 'generic'] as const).map((kind, i) => {
|
||||
const from: AgentMessageSource = {
|
||||
kind: 'agent',
|
||||
senders: (kind === 'generic'
|
||||
? ['A sender on another host with a long name', null, 'Parser worker', 'Fourth sender']
|
||||
: ['Parser worker']
|
||||
).map((name, index) => {
|
||||
const address =
|
||||
kind === 'generic' && index === 0
|
||||
? 'dispatch:missing'
|
||||
: kind === 'task'
|
||||
? 'term_progress'
|
||||
: `term_sender${index || ''}`
|
||||
return {
|
||||
party: {
|
||||
address,
|
||||
terminalHandle: kind === 'generic' && index === 0 ? null : address,
|
||||
orcaSessionId: null
|
||||
},
|
||||
name,
|
||||
...(kind === 'worker' || kind === 'coordinator' ? { relation: kind } : {})
|
||||
}
|
||||
}),
|
||||
orchestration:
|
||||
kind === 'task'
|
||||
? { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' }
|
||||
: {
|
||||
message: 'mail-notice',
|
||||
mailbox: 'term_recipient',
|
||||
dispatchId: null,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
return (
|
||||
<NativeChatAgentMessageCard
|
||||
key={kind}
|
||||
message={{
|
||||
id: kind,
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{ type: 'text', text: i === 0 ? 'A short task fits without a disclosure.' : prose },
|
||||
...(kind === 'generic'
|
||||
? [{ type: 'image-ref' as const, url: image, alt: 'Attached diagram' }]
|
||||
: [])
|
||||
],
|
||||
timestamp: 1_800_000_000_000,
|
||||
from
|
||||
}}
|
||||
from={from}
|
||||
runtimeContext={{
|
||||
worktreeId: workspace,
|
||||
worktreePath: null,
|
||||
target: { kind: 'local' }
|
||||
}}
|
||||
allowFileUriLinks={false}
|
||||
onRefolded={() => {}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<NativeChatUserMessageRow
|
||||
message={human}
|
||||
markdown="An ordinary human message."
|
||||
prose={human.blocks}
|
||||
rowRef={{ current: null }}
|
||||
allowFileUriLinks={false}
|
||||
onRefolded={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
|
||||
for (const [id, agentType, state] of [
|
||||
['sender', 'codex', 'done'],
|
||||
['progress', 'claude', 'working']
|
||||
] as const) {
|
||||
const paneKey = `${id}:11111111-1111-4111-8111-111111111111`
|
||||
agentStatusByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
worktreeId: workspace,
|
||||
terminalHandle: `term_${id}`,
|
||||
agentType,
|
||||
state,
|
||||
prompt: 'Parser worker',
|
||||
updatedAt: 1_800_000_000_000,
|
||||
stateStartedAt: 1_800_000_000_000,
|
||||
stateHistory: [],
|
||||
...(state === 'working'
|
||||
? { programStatus: { state, progress: 40, observedAt: 1_800_000_000_000 } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
const store = {
|
||||
activeWorktreeId: workspace,
|
||||
activeWorkspaceExecutionHostId: 'local' as const,
|
||||
tabsByWorktree: {
|
||||
[workspace]: ['sender', 'progress'].map((id) => makeTab({ id, worktreeId: workspace }))
|
||||
},
|
||||
agentStatusByPaneKey
|
||||
}
|
||||
|
||||
export const wide = defineFixture({ width: 720, store, render: cards })
|
||||
export const narrow = defineFixture({ width: 340, store, render: cards })
|
||||
export const expanded = defineFixture({
|
||||
width: 720,
|
||||
store,
|
||||
render: cards,
|
||||
interact: (frame) => {
|
||||
for (const button of frame.querySelectorAll('button')) {
|
||||
if (button.textContent === 'Show more') {
|
||||
button.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,614 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, renderHook, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { render } from './native-chat-tooltip-test-harness'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers'
|
||||
import { readStoreListenerCount } from '@/store/store-listener-census'
|
||||
import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AgentMessageSenderView } from '../../../../shared/agent-session/agent-message-presentation'
|
||||
import { runtimeEnvironmentStatusFromSnapshot } from '../../../../shared/runtime-host-status'
|
||||
import * as presentation from '@/lib/program-status/agent-row-presentation'
|
||||
import * as cache from '@/components/dashboard/worktree-agent-rows-cache'
|
||||
import * as selectors from '@/components/sidebar/worktree-agent-row-selectors'
|
||||
import { useAgentMessageSenderView } from './native-chat-agent-message-sender-view'
|
||||
import { useAgentMessageSenderSource } from './native-chat-agent-message-sender-source'
|
||||
import { AgentStateDot } from '@/components/AgentStateDot'
|
||||
import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy'
|
||||
import * as markdown from './NativeChatMarkdown'
|
||||
import * as display from '../../../../shared/agent-session/agent-message-display-text'
|
||||
import type * as SonnerModule from 'sonner'
|
||||
import type * as RuntimeRpc from '@/runtime/runtime-rpc-client'
|
||||
import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
|
||||
import { nativeChatSlotCopyText } from './native-chat-message-copy-text'
|
||||
import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard'
|
||||
|
||||
const navigation = vi.hoisted(() => ({ rpc: vi.fn(), toast: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (original) => ({
|
||||
...(await original<typeof RuntimeRpc>()),
|
||||
callRuntimeRpc: navigation.rpc
|
||||
}))
|
||||
vi.mock('sonner', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof SonnerModule>()
|
||||
return {
|
||||
...original,
|
||||
toast: Object.assign(
|
||||
function (this: unknown, ...args: Parameters<typeof original.toast>) {
|
||||
return original.toast.apply(this, args)
|
||||
},
|
||||
original.toast,
|
||||
{ error: navigation.toast }
|
||||
)
|
||||
}
|
||||
})
|
||||
const initialStore = useAppStore.getState()
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const renders = new Map<number, number>()
|
||||
const views = new Map<number, AgentMessageSenderView>()
|
||||
function status(i: number, worktreeId = 'workspace'): AgentStatusEntry {
|
||||
return {
|
||||
paneKey: `tab-${i}:${LEAF}`,
|
||||
worktreeId,
|
||||
terminalHandle: `term_${i}`,
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
prompt: 'work',
|
||||
updatedAt: 2_000_000,
|
||||
stateStartedAt: 2_000_000,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
function Probe({
|
||||
id,
|
||||
workspace = 'workspace',
|
||||
remote = false
|
||||
}: {
|
||||
id: number
|
||||
workspace?: string
|
||||
remote?: boolean
|
||||
}) {
|
||||
const value = useAgentMessageSenderView(
|
||||
{
|
||||
party: { address: `term_${id}`, terminalHandle: `term_${id}`, orcaSessionId: null },
|
||||
name: 'Sender'
|
||||
},
|
||||
workspace,
|
||||
remote ? { kind: 'environment', environmentId: 'remote' } : { kind: 'local' }
|
||||
)
|
||||
views.set(id, value)
|
||||
renders.set(id, (renders.get(id) ?? 0) + 1)
|
||||
return null
|
||||
}
|
||||
function write(i: number, update: Partial<AgentStatusEntry>) {
|
||||
const state = useAppStore.getState()
|
||||
const key = status(i).paneKey
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
...state.agentStatusByPaneKey,
|
||||
[key]: { ...state.agentStatusByPaneKey[key], ...update }
|
||||
},
|
||||
agentStatusEpoch: state.agentStatusEpoch + 1
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let height = 48
|
||||
let resize: () => void
|
||||
let disconnects = 0
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(2_000_000)
|
||||
renders.clear()
|
||||
views.clear()
|
||||
useAppStore.setState(
|
||||
{
|
||||
...initialStore,
|
||||
activeWorktreeId: 'workspace',
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
tabsByWorktree: { workspace: [makeTab({ id: 'tab-0', worktreeId: 'workspace' })] },
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: { [status(0).paneKey]: status(0) },
|
||||
retainedAgentsByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
ptyIdsByTabId: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
agentStatusEpoch: 1
|
||||
},
|
||||
true
|
||||
)
|
||||
navigation.rpc.mockReset()
|
||||
navigation.toast.mockReset()
|
||||
height = 48
|
||||
disconnects = 0
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => {
|
||||
return height
|
||||
})
|
||||
const computedStyle = getComputedStyle
|
||||
vi.stubGlobal('getComputedStyle', (element: Element) => {
|
||||
const style = computedStyle(element)
|
||||
return new Proxy(style, {
|
||||
get(target, key) {
|
||||
if (key === 'lineHeight') {
|
||||
return '24px'
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the style facade delegates native properties to the real style receiver, including binding its methods below.
|
||||
const value = target[key as keyof CSSStyleDeclaration]
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
}
|
||||
})
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
resize = callback
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
disconnects++
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialStore, true)
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const from: AgentMessageSource = {
|
||||
kind: 'agent',
|
||||
orchestration: null,
|
||||
senders: ['Build cards', 'Other sender'].map((name, i) => ({
|
||||
name,
|
||||
party: { address: `term_${i}`, terminalHandle: `term_${i}`, orcaSessionId: null }
|
||||
}))
|
||||
}
|
||||
const context = { worktreeId: 'workspace', worktreePath: null, target: { kind: 'local' as const } }
|
||||
function card(message: NativeChatMessage) {
|
||||
return (
|
||||
<NativeChatAgentMessageCard
|
||||
message={message}
|
||||
from={message.from!}
|
||||
runtimeContext={context}
|
||||
allowFileUriLinks={false}
|
||||
onRefolded={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
function slots(message: NativeChatMessage) {
|
||||
return buildNativeChatTranscriptSlots({
|
||||
messages: [message],
|
||||
turnKeys: [undefined],
|
||||
liveTurnKey: undefined,
|
||||
receipts: new Map(),
|
||||
turnStatuses: { active: null, completedByTurn: {} },
|
||||
turnDiffs: new Map(),
|
||||
expandedTurnKeys: new Set(),
|
||||
isWorking: false,
|
||||
lifecycleWorking: false
|
||||
})
|
||||
}
|
||||
|
||||
it.each(['task', 'mail'] as const)(
|
||||
'keeps native %s progress independent from Markdown and reparses own edits',
|
||||
async (kind) => {
|
||||
const paneKey = status(0).paneKey
|
||||
const entry = {
|
||||
...status(0),
|
||||
programStatus: { state: 'working' as const, progress: 40, observedAt: 2_000_000 }
|
||||
}
|
||||
useAppStore.setState({ agentStatusByPaneKey: { [paneKey]: entry } })
|
||||
const prefix =
|
||||
kind === 'task' ? 'You are working inside Orca, a multi-agent IDE.\n=== TASK ===\n' : ''
|
||||
const message: NativeChatMessage = {
|
||||
id: `progress-${kind}`,
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
from: {
|
||||
...from,
|
||||
orchestration:
|
||||
kind === 'task'
|
||||
? { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' }
|
||||
: { message: 'mail-notice', mailbox: 'term_0', dispatchId: null, messages: [] }
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${prefix}Current **task** with [link](https://example.com) and .\n\nSecond paragraph with [another link](https://example.com/second).`
|
||||
}
|
||||
]
|
||||
}
|
||||
const convert = vi.spyOn(display, 'agentMessageTranscriptText')
|
||||
const renderMarkdown = vi.spyOn(markdown, 'NativeChatMarkdown')
|
||||
let mounted = render(card(message))
|
||||
expect(
|
||||
screen.getByText(kind === 'task' ? 'Task from your coordinator' : 'Message from')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('task', { selector: 'strong' })).toBeInTheDocument()
|
||||
expect(convert).toHaveBeenCalled()
|
||||
expect(renderMarkdown).toHaveBeenCalled()
|
||||
expect(mounted.container.querySelectorAll('p')).toHaveLength(2)
|
||||
expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com'
|
||||
)
|
||||
expect(screen.getByRole('img', { name: 'image' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
const text =
|
||||
'Current **task** with [link](https://example.com) and .\n\nSecond paragraph with [another link](https://example.com/second).'
|
||||
const writeClipboardText = vi.fn(async () => {})
|
||||
vi.stubGlobal('api', { ui: { writeClipboardText } })
|
||||
navigation.rpc.mockRejectedValue(new Error('unreachable'))
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Build cards' })))
|
||||
expect(navigation.rpc).toHaveBeenCalledWith({ kind: 'local' }, 'orchestration.partyLocation', {
|
||||
address: 'term_0'
|
||||
})
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Other sender' })))
|
||||
expect(navigation.rpc).toHaveBeenLastCalledWith(
|
||||
{ kind: 'local' },
|
||||
'orchestration.partyLocation',
|
||||
{ address: 'term_1' }
|
||||
)
|
||||
expect(navigation.toast).toHaveBeenCalledWith(
|
||||
`${AGENT_SESSION_WRITE_NOTICE_COPY.unreachable} ${AGENT_SESSION_WRITE_NOTICE_COPY.tryAgain}`
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull()
|
||||
act(() => {
|
||||
height = 120
|
||||
resize()
|
||||
})
|
||||
const more = screen.getByRole('button', { name: 'Show more' })
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false')
|
||||
const clip = document.getElementById(more.getAttribute('aria-controls')!)!
|
||||
const first = screen.getByRole('link', { name: 'link' })
|
||||
const second = screen.getByRole('link', { name: 'another link' })
|
||||
const documentFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
first.focus()
|
||||
clip.scrollTop = 74
|
||||
second.focus()
|
||||
expect(second).toHaveFocus()
|
||||
expect(clip.scrollTop).toBe(74)
|
||||
documentFocus.mockReturnValue(false)
|
||||
second.blur()
|
||||
expect(clip.scrollTop).toBe(74)
|
||||
documentFocus.mockReturnValue(true)
|
||||
second.focus()
|
||||
more.focus()
|
||||
expect(more).toHaveFocus()
|
||||
expect(clip.scrollTop).toBe(0)
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false')
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Copy message' })))
|
||||
expect(writeClipboardText).toHaveBeenCalledWith(text)
|
||||
expect(nativeChatSlotCopyText(slots(message), message.id)).toBe(text)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show less' }))
|
||||
act(() => {
|
||||
height = 48
|
||||
resize()
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull()
|
||||
for (const remount of [false, true]) {
|
||||
if (remount) {
|
||||
mounted.unmount()
|
||||
mounted = render(card(message))
|
||||
}
|
||||
const ring = screen.getByRole('progressbar', { name: 'Working · 40%' })
|
||||
expect(ring).toHaveAttribute('aria-valuenow', '40')
|
||||
expect(ring.querySelector('circle[pathLength="100"]')).toHaveAttribute(
|
||||
'stroke-dasharray',
|
||||
'40 100'
|
||||
)
|
||||
}
|
||||
for (const spy of [convert, renderMarkdown]) {
|
||||
spy.mockClear()
|
||||
}
|
||||
for (const progress of [60, null, 60, 0, 100]) {
|
||||
act(() =>
|
||||
useAppStore
|
||||
.getState()
|
||||
.setAgentProgramStatus(
|
||||
paneKey,
|
||||
progress === null ? null : { state: 'working', progress, observedAt: 2_000_000 }
|
||||
)
|
||||
)
|
||||
if (progress === null) {
|
||||
expect(screen.queryByRole('progressbar')).toBeNull()
|
||||
expect(screen.getByLabelText('Working')).toBeInTheDocument()
|
||||
} else {
|
||||
const ring = screen.getByRole('progressbar', { name: `Working · ${progress}%` })
|
||||
expect(ring).toHaveAttribute('aria-valuenow', String(progress))
|
||||
expect(ring.querySelector('circle[pathLength="100"]')).toHaveAttribute(
|
||||
'stroke-dasharray',
|
||||
`${progress} 100`
|
||||
)
|
||||
expect(screen.queryByLabelText('Done')).toBeNull()
|
||||
}
|
||||
for (const spy of [convert, renderMarkdown]) {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: { [paneKey]: { ...entry, state: 'done' } },
|
||||
agentStatusEpoch: 2
|
||||
})
|
||||
)
|
||||
expect(screen.getByLabelText('Done')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('progressbar')).toBeNull()
|
||||
for (const spy of [convert, renderMarkdown]) {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
}
|
||||
const edited = { ...message, blocks: [{ type: 'text' as const, text: `${prefix}Edited task` }] }
|
||||
mounted.rerender(card(edited))
|
||||
expect(screen.getByText('Edited task')).toBeInTheDocument()
|
||||
expect(convert).toHaveBeenCalled()
|
||||
expect(renderMarkdown).toHaveBeenCalled()
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy message' })
|
||||
await act(async () => fireEvent.click(copyButton))
|
||||
expect(copyButton).toHaveAccessibleName('Copied')
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('Edited task')
|
||||
expect(nativeChatSlotCopyText(slots(edited), edited.id)).toBe('Edited task')
|
||||
const multiple: NativeChatMessage = {
|
||||
...edited,
|
||||
blocks: [...edited.blocks, { type: 'text', text: 'Another paragraph' }]
|
||||
}
|
||||
mounted.rerender(card(multiple))
|
||||
await act(async () => fireEvent.click(copyButton))
|
||||
expect(writeClipboardText).toHaveBeenCalledWith(`${prefix}Edited task\n\nAnother paragraph`)
|
||||
expect(nativeChatSlotCopyText(slots(multiple), multiple.id)).toBe(
|
||||
`${prefix}Edited task\n\nAnother paragraph`
|
||||
)
|
||||
mounted.unmount()
|
||||
expect(disconnects).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([100, 1000])(
|
||||
'shares actual row acquisition and isolates selected progress across %i cards',
|
||||
(n) => {
|
||||
let visits = 0
|
||||
const live = new Proxy(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
const entry = status(i)
|
||||
return [entry.paneKey, entry]
|
||||
})
|
||||
),
|
||||
{
|
||||
ownKeys(target) {
|
||||
const keys = Object.getOwnPropertyNames(target)
|
||||
visits += keys.length
|
||||
return keys
|
||||
}
|
||||
}
|
||||
)
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: live,
|
||||
tabsByWorktree: {
|
||||
workspace: Array.from({ length: n }, (_, i) =>
|
||||
makeTab({ id: `tab-${i}`, worktreeId: 'workspace' })
|
||||
)
|
||||
},
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
Array.from({ length: n }, (_, i): [string, TerminalLayoutSnapshot] => [
|
||||
`tab-${i}`,
|
||||
{ root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null }
|
||||
])
|
||||
)
|
||||
})
|
||||
const normalize = vi.spyOn(presentation, 'resolveRowProgramStatus')
|
||||
const mounted = render(
|
||||
<>
|
||||
{Array.from({ length: n }, (_, id) => (
|
||||
<Probe key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
// Handle lookup, visible-authority acquisition and sidebar indexing each enumerate the N live entries.
|
||||
expect(visits).toBe(n * 3)
|
||||
expect(normalize).toHaveBeenCalledTimes(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
expect(views.get(i)?.dot).toBe('working')
|
||||
}
|
||||
const before = new Map(renders)
|
||||
visits = 0
|
||||
normalize.mockClear()
|
||||
act(() => useAppStore.setState({ sidebarBody: 'agents' }))
|
||||
expect(visits).toBe(0)
|
||||
expect(normalize).not.toHaveBeenCalled()
|
||||
expect(renders).toEqual(before)
|
||||
for (const progress of [40, 60]) {
|
||||
normalize.mockClear()
|
||||
const epoch = useAppStore.getState().agentStatusEpoch
|
||||
act(() =>
|
||||
useAppStore.getState().setAgentProgramStatus(status(0).paneKey, {
|
||||
state: 'working',
|
||||
progress,
|
||||
observedAt: 2_000_000
|
||||
})
|
||||
)
|
||||
expect(useAppStore.getState().agentStatusEpoch).toBe(epoch)
|
||||
expect(normalize).toHaveBeenCalledTimes(n)
|
||||
expect(views.get(0)?.progress).toBe(progress)
|
||||
expect(renders.get(0)).toBe(before.get(0)! + (progress === 40 ? 1 : 2))
|
||||
for (let i = 1; i < n; i++) {
|
||||
expect(renders.get(i)).toBe(before.get(i))
|
||||
}
|
||||
}
|
||||
mounted.unmount()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps other hosts isolated, retains completed senders, and releases disconnected subscriptions', () => {
|
||||
const create = vi.spyOn(cache, 'createWorktreeAgentRowsCache')
|
||||
const baseline = readStoreListenerCount()
|
||||
expect(baseline).not.toBeNull()
|
||||
const other = status(9, 'remote')
|
||||
useAppStore.setState({
|
||||
repos: [TEST_REPO],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
makeWorktree({ id: 'remote', repoId: TEST_REPO.id, hostId: 'runtime:remote' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
...useAppStore.getState().tabsByWorktree,
|
||||
remote: [makeTab({ id: 'tab-9', worktreeId: 'remote' })]
|
||||
},
|
||||
agentStatusByPaneKey: { ...useAppStore.getState().agentStatusByPaneKey, [other.paneKey]: other }
|
||||
})
|
||||
const mounted = render(
|
||||
<>
|
||||
<Probe id={0} />
|
||||
<Probe id={9} workspace="remote" remote />
|
||||
</>
|
||||
)
|
||||
expect(readStoreListenerCount()).toBeGreaterThan(baseline!)
|
||||
const leased = create.mock.results.flatMap((result) =>
|
||||
result.type === 'return' && result.value.byWorktree.size ? [result.value] : []
|
||||
)
|
||||
expect(leased.flatMap((value) => [...value.byWorktree.keys()]).sort()).toEqual([
|
||||
'remote',
|
||||
'workspace'
|
||||
])
|
||||
const before = new Map(renders)
|
||||
write(9, { state: 'waiting' })
|
||||
expect(views.get(9)?.dot).toBe('waiting')
|
||||
expect(renders.get(9)).toBe(before.get(9)! + 1)
|
||||
expect(renders.get(0)).toBe(before.get(0))
|
||||
write(0, {
|
||||
agentType: 'codex',
|
||||
programStatus: { state: 'blocked', progress: 60, observedAt: 2_000_000 }
|
||||
})
|
||||
expect(views.get(0)).toEqual({ agentType: 'codex', dot: 'permission', progress: 60 })
|
||||
expect(renders.get(9)).toBe(before.get(9)! + 1)
|
||||
for (const value of leased) {
|
||||
expect(value.lastComputedWorktreeIds.length).toBeLessThanOrEqual(1)
|
||||
}
|
||||
const entry = { ...status(0), state: 'done' as const }
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: { [other.paneKey]: other },
|
||||
retainedAgentsByPaneKey: {
|
||||
[entry.paneKey]: {
|
||||
entry,
|
||||
tab: makeTab({ id: 'tab-0', worktreeId: 'workspace' }),
|
||||
worktreeId: 'workspace',
|
||||
agentType: 'claude',
|
||||
startedAt: 1
|
||||
}
|
||||
},
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'remote',
|
||||
runtimeEnvironmentStatusFromSnapshot({
|
||||
environmentId: 'remote',
|
||||
pairingRevision: 1,
|
||||
sequence: 1,
|
||||
checkedAt: 1,
|
||||
verification: 'blocked',
|
||||
transport: 'disconnected',
|
||||
retired: true,
|
||||
status: null
|
||||
})
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
expect(views.get(0)?.dot).toBe('done')
|
||||
act(() => useAppStore.setState({ activeWorkspaceExecutionHostId: 'runtime:remote' }))
|
||||
expect(views.get(0)).toEqual({})
|
||||
act(() => useAppStore.setState({ activeWorkspaceExecutionHostId: 'local' }))
|
||||
expect(views.get(0)?.dot).toBe('done')
|
||||
const disconnected = new Map(renders)
|
||||
const constructions = create.mock.calls.length
|
||||
act(() => useAppStore.setState({ sidebarBody: 'agents' }))
|
||||
expect(renders).toEqual(disconnected)
|
||||
expect(create).toHaveBeenCalledTimes(constructions)
|
||||
mounted.unmount()
|
||||
for (const value of leased) {
|
||||
expect(value.byWorktree.size).toBe(0)
|
||||
}
|
||||
expect(readStoreListenerCount()).toBe(baseline)
|
||||
render(<Probe id={0} />)
|
||||
expect(views.get(0)?.dot).toBe('done')
|
||||
})
|
||||
|
||||
it('uses the child row own glyph even when its parent program needs permission', () => {
|
||||
write(0, {
|
||||
programStatus: { state: 'blocked', progress: 60, observedAt: 2_000_000 },
|
||||
subagents: [{ id: 'child', state: 'working', startedAt: 2_000_000, description: 'Child' }]
|
||||
})
|
||||
const state = useAppStore.getState()
|
||||
const projected = cache.selectWorktreeAgentRowsCached({
|
||||
state,
|
||||
worktreeId: 'workspace',
|
||||
now: 2_000_000,
|
||||
generation: '',
|
||||
orchestration: selectors.selectRuntimeAgentOrchestrationForWorktree(state, 'workspace')
|
||||
})
|
||||
const child = projected.find((row) => row.childRow)!
|
||||
expect(child).toBeDefined()
|
||||
const locate = () => ({ host: 'local', worktreeId: 'workspace', paneKey: child.paneKey })
|
||||
let value: AgentMessageSenderView | undefined
|
||||
function Child() {
|
||||
value = useAgentMessageSenderSource(locate)
|
||||
return value.dot ? (
|
||||
<AgentStateDot state={value.dot} progress={value.progress} size="md" title={null} />
|
||||
) : null
|
||||
}
|
||||
render(<Child />)
|
||||
expect(value?.dot).toBe('working')
|
||||
expect(value?.progress).toBeUndefined()
|
||||
expect(screen.queryByRole('progressbar')).toBeNull()
|
||||
expect(screen.getByLabelText('Working')).toBeDefined()
|
||||
})
|
||||
|
||||
it('refuses a target-less ambiguous workspace twin while an explicit local target resolves', () => {
|
||||
const workspace = `${TEST_REPO.id}::/w`
|
||||
const entry = status(0, workspace)
|
||||
useAppStore.setState({
|
||||
repos: [TEST_REPO],
|
||||
activeWorktreeId: 'different',
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
detectedWorktreesByRepo: {},
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
makeWorktree({ id: workspace, repoId: TEST_REPO.id, hostId: 'local' }),
|
||||
makeWorktree({ id: workspace, repoId: TEST_REPO.id, hostId: 'runtime:e' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { [workspace]: [makeTab({ id: 'tab-0', worktreeId: workspace })] },
|
||||
agentStatusByPaneKey: { [entry.paneKey]: entry }
|
||||
})
|
||||
const state = useAppStore.getState()
|
||||
expect(getKnownExecutionHostIdForWorktree(state, workspace)).toBe('local')
|
||||
expect(resolveStructuredAgentSessionOwner(state, workspace)).toBeNull()
|
||||
const view = renderHook(() => useAgentMessageSenderView(from.senders[0], workspace))
|
||||
expect(view.result.current).toEqual({})
|
||||
view.unmount()
|
||||
const explicit = renderHook(() =>
|
||||
useAgentMessageSenderView(from.senders[0], workspace, { kind: 'local' })
|
||||
)
|
||||
expect(explicit.result.current.dot).toBe('working')
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { AgentStateDot } from '@/components/AgentStateDot'
|
||||
import { AgentTypeIcon } from '@/lib/agent-type-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source'
|
||||
import {
|
||||
agentMessageHeading,
|
||||
agentMessagePrimarySender,
|
||||
agentMessageOverflows,
|
||||
agentMessageFoldLabel,
|
||||
agentMessageCanCopy,
|
||||
AGENT_MESSAGE_FOLDED_LINES
|
||||
} from '../../../../shared/agent-session/agent-message-presentation'
|
||||
import { agentMessageTranscriptText } from '../../../../shared/agent-session/agent-message-display-text'
|
||||
import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders'
|
||||
import { useAgentMessageSenderView } from './native-chat-agent-message-sender-view'
|
||||
import { useNativeChatVisualOwner } from './native-chat-visual-owner'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { NativeChatMarkdown } from './NativeChatMarkdown'
|
||||
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
|
||||
import { NativeChatCopyButton } from './NativeChatCopyButton'
|
||||
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
|
||||
import { NativeChatImageAttachments } from './NativeChatTranscriptChrome'
|
||||
|
||||
function heading(from: AgentMessageSource): string {
|
||||
switch (agentMessageHeading(from)) {
|
||||
case 'task':
|
||||
return translate(
|
||||
'components.native-chat.agentMessage.taskFromCoordinator',
|
||||
'Task from your coordinator'
|
||||
)
|
||||
case 'coordinator':
|
||||
return translate(
|
||||
'components.native-chat.agentMessage.messageFromCoordinator',
|
||||
'Message from your coordinator'
|
||||
)
|
||||
case 'worker':
|
||||
return translate(
|
||||
'components.native-chat.agentMessage.updateFromWorker',
|
||||
'Update from your worker'
|
||||
)
|
||||
case 'generic':
|
||||
return translate('components.native-chat.agentMessage.messageFrom', 'Message from')
|
||||
}
|
||||
}
|
||||
|
||||
export function NativeChatAgentMessageCard({
|
||||
message,
|
||||
from,
|
||||
runtimeContext,
|
||||
onLinkClick,
|
||||
allowFileUriLinks,
|
||||
onRefolded
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
from: AgentMessageSource
|
||||
runtimeContext?: RuntimeFileOperationArgs | null
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
allowFileUriLinks: boolean
|
||||
onRefolded: () => void
|
||||
}): React.JSX.Element {
|
||||
const owner = useNativeChatVisualOwner()
|
||||
const worktreeId = owner?.worktreeId ?? runtimeContext?.worktreeId ?? null
|
||||
const target = owner?.target ?? runtimeContext?.target
|
||||
const text = agentMessageTranscriptText(message.blocks, from)
|
||||
const disclosure = useNativeChatDisclosure(`agent-message:${message.id}`, false)
|
||||
const clip = useRef<HTMLDivElement>(null)
|
||||
const clipId = useId()
|
||||
const [overflows, setOverflows] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const element = clip.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
const measure = () => {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight)
|
||||
setOverflows(agentMessageOverflows(element.scrollHeight, lineHeight))
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [text])
|
||||
return (
|
||||
<div className="group flex w-full gap-3 rounded-xl border border-border bg-card p-3 text-sm text-card-foreground">
|
||||
<AgentMessageAvatar
|
||||
sender={agentMessagePrimarySender(from)}
|
||||
worktreeId={worktreeId}
|
||||
target={target}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{heading(from)}</span>
|
||||
<NativeChatMessageTimestamp
|
||||
timestamp={message.timestamp}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<NativeChatAgentMessageSenders from={from} chatWorktreeId={worktreeId} target={target} />
|
||||
<NativeChatImageAttachments
|
||||
blocks={message.blocks}
|
||||
runtimeContext={runtimeContext}
|
||||
enablePreview={runtimeContext !== undefined}
|
||||
/>
|
||||
<div
|
||||
ref={clip}
|
||||
id={clipId}
|
||||
className={cn(
|
||||
'overflow-hidden leading-6',
|
||||
overflows && !disclosure.open && 'mask-b-from-[calc(100%-1.75rem)]'
|
||||
)}
|
||||
style={disclosure.open ? undefined : { maxHeight: `${AGENT_MESSAGE_FOLDED_LINES}lh` }}
|
||||
onBlur={(event) => {
|
||||
if (document.hasFocus() && !event.currentTarget.contains(event.relatedTarget)) {
|
||||
event.currentTarget.scrollTop = 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NativeChatMarkdown
|
||||
rowKey={message.id}
|
||||
content={text}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
{overflows ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-expanded={disclosure.open}
|
||||
aria-controls={clipId}
|
||||
onClick={() => {
|
||||
disclosure.setOpen(!disclosure.open)
|
||||
if (disclosure.open) {
|
||||
requestAnimationFrame(onRefolded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{agentMessageFoldLabel(disclosure.open) === 'expanded'
|
||||
? translate('components.native-chat.userMessage.showLess', 'Show less')
|
||||
: translate('components.native-chat.agentMessage.showMore', 'Show more')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{agentMessageCanCopy(text) ? (
|
||||
<NativeChatCopyButton
|
||||
text={text}
|
||||
className="transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentMessageAvatar({
|
||||
sender,
|
||||
worktreeId,
|
||||
target
|
||||
}: {
|
||||
sender: AgentMessageSource['senders'][number] | undefined
|
||||
worktreeId: string | null
|
||||
target?: RuntimeClientTarget
|
||||
}): React.JSX.Element {
|
||||
const view = useAgentMessageSenderView(sender, worktreeId, target)
|
||||
return (
|
||||
<div className="relative mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted">
|
||||
<AgentTypeIcon agentType={view.agentType} size={16} />
|
||||
{view.dot ? (
|
||||
<span className="absolute -right-0.5 -bottom-0.5 rounded-full bg-card ring-2 ring-card">
|
||||
<AgentStateDot state={view.dot} size="md" progress={view.progress} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,9 @@ describe("another agent's message in the transcript", () => {
|
||||
const source = from([{ address: CODER.address, name: 'Coder' }])
|
||||
renderUserMessage(source)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Coder' }))
|
||||
expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat')
|
||||
expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat', {
|
||||
kind: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates the names as the queued card does, and keeps two same-named senders two', () => {
|
||||
@@ -77,8 +79,9 @@ describe("another agent's message in the transcript", () => {
|
||||
{ address: 'term_b', name: 'Codex' }
|
||||
])
|
||||
)
|
||||
expect(screen.getByText('Message from')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Codex' })).toHaveLength(2)
|
||||
expect(container.textContent).toContain('Message fromCodex,Codex')
|
||||
expect(container.textContent).toContain('Codex,Codex')
|
||||
})
|
||||
|
||||
it('names each sender, an unnamed one as an agent, and counts the rest', () => {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('the name a sender is shown under', () => {
|
||||
agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) }
|
||||
})
|
||||
renderSenders(source({}, 'Recorded name'))
|
||||
expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recorded name')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Parser worker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('the name a sender is shown under', () => {
|
||||
tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'New name' })] }
|
||||
})
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recorded name')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('the name a sender is shown under', () => {
|
||||
orcaSessionId: SESSION
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Port the parser' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Port the parser')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the recorded name for a chat sender whose tab has no name of its own', () => {
|
||||
@@ -155,7 +155,7 @@ describe('the name a sender is shown under', () => {
|
||||
'Port the parser task'
|
||||
)
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Port the parser task' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Port the parser task')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the recorded name when its agent row has no name of its own', () => {
|
||||
@@ -164,12 +164,12 @@ describe('the name a sender is shown under', () => {
|
||||
agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) }
|
||||
})
|
||||
renderSenders(source({}))
|
||||
expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recorded name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the recorded name once the sender is gone from this window', () => {
|
||||
renderSenders(source({}))
|
||||
expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recorded name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the recorded name for a sender on another host, whatever runs here', () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Fragment } from 'react'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { openAgentMessageSender } from '@/lib/open-agent-message-sender'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { agentMessageSenderCanOpen } from '../../../../shared/agent-session/agent-message-presentation'
|
||||
import { useNativeChatVisualOwner } from './native-chat-visual-owner'
|
||||
import {
|
||||
agentMessageSendersShown,
|
||||
@@ -18,34 +20,39 @@ import {
|
||||
export function NativeChatAgentMessageSenders({
|
||||
from,
|
||||
chatWorktreeId,
|
||||
target,
|
||||
queued = false
|
||||
}: {
|
||||
from: AgentMessageSource
|
||||
chatWorktreeId: string | null
|
||||
target?: RuntimeClientTarget
|
||||
queued?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { shown, more } = agentMessageSendersShown(from)
|
||||
const owner = useNativeChatVisualOwner()
|
||||
const worktreeId = owner?.worktreeId ?? chatWorktreeId
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center text-xs text-chat-foreground-faint">
|
||||
<div
|
||||
className={`flex min-w-0 max-w-full flex-wrap items-center ${queued ? 'text-xs text-chat-foreground-faint' : '-ml-2.5 text-sm text-foreground'}`}
|
||||
>
|
||||
<span>
|
||||
{queued
|
||||
? translate('components.native-chat.queuedMessages.fromSender', 'From')
|
||||
: translate('components.native-chat.agentMessage.messageFrom', 'Message from')}
|
||||
{queued ? translate('components.native-chat.queuedMessages.fromSender', 'From') : null}
|
||||
</span>
|
||||
{shown.length === 0 ? <span className="px-2">{unnamedSenderLabel()}</span> : null}
|
||||
{shown.length === 0 ? (
|
||||
<span className={queued ? 'px-2' : 'px-2.5'}>{unnamedSenderLabel()}</span>
|
||||
) : null}
|
||||
{shown.map((sender, index) => (
|
||||
<Fragment key={sender.party.address}>
|
||||
<SenderName
|
||||
from={from}
|
||||
sender={sender}
|
||||
chatWorktreeId={worktreeId}
|
||||
target={owner?.target}
|
||||
target={owner?.target ?? target}
|
||||
queued={queued}
|
||||
/>
|
||||
{/* Pulled back over the name's padding, so it reads "A, B". */}
|
||||
{index < shown.length - 1 ? (
|
||||
<span aria-hidden className="-ml-2">
|
||||
<span aria-hidden className={queued ? '-ml-2' : '-ml-2.5'}>
|
||||
,
|
||||
</span>
|
||||
) : null}
|
||||
@@ -60,17 +67,19 @@ function SenderName({
|
||||
from,
|
||||
sender,
|
||||
chatWorktreeId,
|
||||
target
|
||||
target,
|
||||
queued
|
||||
}: {
|
||||
from: AgentMessageSource
|
||||
sender: AgentMessageSender
|
||||
chatWorktreeId: string | null
|
||||
target?: RuntimeClientTarget
|
||||
queued: boolean
|
||||
}): React.JSX.Element {
|
||||
const label = useAgentMessageSenderLabel(sender, chatWorktreeId, target)
|
||||
if (!chatWorktreeId || !opensFromHere(sender)) {
|
||||
if (!agentMessageSenderCanOpen(sender, Boolean(chatWorktreeId)) || !chatWorktreeId) {
|
||||
return (
|
||||
<span className="max-w-48 truncate px-2" title={label}>
|
||||
<span className={`max-w-48 truncate ${queued ? 'px-2' : 'px-2.5'}`} title={label}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
@@ -78,9 +87,11 @@ function SenderName({
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
title={label}
|
||||
variant={queued ? 'link' : 'ghost'}
|
||||
size={queued ? 'xs' : 'sm'}
|
||||
title={
|
||||
queued ? label : translate('components.native-chat.agentMessage.goToAgent', 'Go to agent')
|
||||
}
|
||||
onClick={() =>
|
||||
void (target
|
||||
? openAgentMessageSender(from, sender, chatWorktreeId, target)
|
||||
@@ -88,11 +99,7 @@ function SenderName({
|
||||
}
|
||||
>
|
||||
<span className="max-w-48 truncate">{label}</span>
|
||||
{!queued ? <ArrowUpRight className="size-3" aria-hidden /> : null}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A federated sender (`dispatch:<id>`) runs on another host, which this chat's host cannot open. */
|
||||
function opensFromHere(sender: AgentMessageSender): boolean {
|
||||
return !sender.party.address.startsWith('dispatch:')
|
||||
}
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
done,
|
||||
script,
|
||||
SESSION,
|
||||
SCOPE,
|
||||
write,
|
||||
writeSpy
|
||||
} from './structured-queued-edit.test-fixture'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
|
||||
import { ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX } from '../../../../shared/orca-dispatch-status-prompt'
|
||||
import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
|
||||
import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards'
|
||||
import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard'
|
||||
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
|
||||
import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support'
|
||||
import { render } from './native-chat-tooltip-test-harness'
|
||||
import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit'
|
||||
|
||||
vi.mock('../../store', async () => {
|
||||
const { create } = await import('zustand')
|
||||
return {
|
||||
useAppStore: create(() => ({
|
||||
dictationState: 'idle',
|
||||
settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
|
||||
agentStatusByPaneKey: {},
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
updateSettings: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
// Keep host discovery and OS file intake outside this text-only queued consumer contract.
|
||||
vi.mock('./use-native-chat-session-options', () => ({
|
||||
useNativeChatSessionOptions: () => ({ surface: null, snapshot: [] })
|
||||
}))
|
||||
vi.mock('./use-native-chat-mention-files', () => ({
|
||||
useNativeChatMentionFiles: () => ({ files: [], loading: false, failed: false })
|
||||
}))
|
||||
vi.mock('./use-native-chat-skills', () => ({
|
||||
useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: vi.fn() })
|
||||
}))
|
||||
vi.mock('./use-native-chat-file-drops', () => ({
|
||||
useNativeChatFileDrops: () => ({
|
||||
pickAttachments: vi.fn(),
|
||||
attachExternalPaths: vi.fn(),
|
||||
captureExternalPaths: vi.fn(),
|
||||
captureCopiedFiles: vi.fn(),
|
||||
resolveAttachmentOwner: () => ({ kind: 'local' })
|
||||
})
|
||||
}))
|
||||
|
||||
const transport: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionSnapshot: [],
|
||||
onError: vi.fn(),
|
||||
runtime: 'local',
|
||||
sessionId: SESSION,
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
|
||||
function QueuedTask({ message }: { message: AgentSessionQueuedMessage }) {
|
||||
const messages = [message]
|
||||
const cards = projectQueuedMessageCards(messages, [], {})
|
||||
const { editor, begin } = useStructuredAgentSessionQueuedEdit({
|
||||
transport: { target: { kind: 'local' }, sessionId: SESSION, capable: true, write },
|
||||
messages,
|
||||
cards,
|
||||
submissions: [],
|
||||
composerScopeKey: SCOPE,
|
||||
promptOpen: false
|
||||
})
|
||||
return (
|
||||
<ul>
|
||||
<NativeChatQueuedMessageCard
|
||||
card={cards[0]!}
|
||||
chatWorktreeId={null}
|
||||
showsSteerShortcut={false}
|
||||
onSteer={() => {}}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => void begin(message.messageId)}
|
||||
editor={editor ?? undefined}
|
||||
editAttachTo={{ terminalTabId: 'tab', transport }}
|
||||
/>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('shows the queued task and edits, saves and reopens its full raw input in the chat composer', async () => {
|
||||
const prefix = `${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX} You are a dispatched worker.\n=== TASK ===\n`
|
||||
const task = 'Inspect the queued task'
|
||||
const changedTask = 'Inspect the edited task'
|
||||
const message: AgentSessionQueuedMessage = {
|
||||
messageId: 'card',
|
||||
position: 1,
|
||||
state: 'waiting',
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: prefix + task }],
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' }
|
||||
}
|
||||
}
|
||||
}
|
||||
// The host's queuedMessageUpdateDecision removes attribution on a content-changing Save.
|
||||
const { from: _sender, ...userBody } = message.body
|
||||
const saved: AgentSessionQueuedMessage = {
|
||||
...message,
|
||||
body: { ...userBody, blocks: [{ type: 'text', text: prefix + changedTask }] }
|
||||
}
|
||||
script.answer = done({
|
||||
status: 'updated',
|
||||
messageId: message.messageId,
|
||||
fingerprint: agentSessionSendBodyFingerprint(SESSION, saved.body)
|
||||
})
|
||||
const view = render(<QueuedTask message={message} />)
|
||||
expect(screen.getByTitle(task).textContent).toBe(task)
|
||||
expect(screen.getByText('From')).toBeDefined()
|
||||
expect(screen.getByText('an agent')).toBeDefined()
|
||||
expect(screen.queryByText(/You are working inside Orca/)).toBeNull()
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' }))
|
||||
const field = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
await waitFor(() => expect(field.getAttribute('contenteditable')).toBe('true'))
|
||||
expect(promptValue(field)).toBe(prefix + task)
|
||||
changePrompt(field, prefix + changedTask)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(writeSpy).toHaveBeenCalledOnce())
|
||||
expect(writeSpy.mock.calls[0]?.[2]).toMatchObject({
|
||||
messageId: 'card',
|
||||
text: prefix + changedTask
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull())
|
||||
|
||||
view.rerender(<QueuedTask message={saved} />)
|
||||
expect(screen.getByTitle(prefix + changedTask, { normalizer: (text) => text }).textContent).toBe(
|
||||
prefix + changedTask
|
||||
)
|
||||
expect(screen.queryByText('From')).toBeNull()
|
||||
expect(screen.queryByText('an agent')).toBeNull()
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' }))
|
||||
const reopened = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
await waitFor(() => expect(reopened.getAttribute('contenteditable')).toBe('true'))
|
||||
expect(promptValue(reopened)).toBe(prefix + changedTask)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull())
|
||||
expect(writeSpy).toHaveBeenCalledOnce()
|
||||
expect(transport.send).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { NativeChatUserMessageFold } from './NativeChatUserMessageFold'
|
||||
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
|
||||
import { NativeChatCopyButton } from './NativeChatCopyButton'
|
||||
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
|
||||
import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders'
|
||||
import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard'
|
||||
import {
|
||||
NativeChatDetailsDisclosure,
|
||||
NativeChatImageAttachments
|
||||
@@ -96,62 +96,62 @@ export function NativeChatUserMessageRow({
|
||||
className={cn('group relative flex flex-col gap-0.5', from ? 'items-start' : 'items-end')}
|
||||
>
|
||||
{from ? (
|
||||
<NativeChatAgentMessageSenders
|
||||
<NativeChatAgentMessageCard
|
||||
message={message}
|
||||
from={from}
|
||||
chatWorktreeId={runtimeContext?.worktreeId ?? null}
|
||||
runtimeContext={runtimeContext}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
onRefolded={onRefolded}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] text-sm',
|
||||
from
|
||||
? 'select-text border-l-2 border-border/60 pl-3 text-chat-foreground'
|
||||
: 'rounded-xl border border-chat-user-border bg-chat-user-surface px-3.5 py-2.5 text-chat-foreground-strong'
|
||||
)}
|
||||
>
|
||||
{markdown ? (
|
||||
<>
|
||||
) : (
|
||||
<div className="max-w-[80%] rounded-xl border border-chat-user-border bg-chat-user-surface px-3.5 py-2.5 text-sm text-chat-foreground-strong">
|
||||
{markdown ? (
|
||||
<>
|
||||
<NativeChatImageAttachments
|
||||
blocks={prose}
|
||||
runtimeContext={runtimeContext}
|
||||
enablePreview={runtimeContext !== undefined}
|
||||
/>
|
||||
<NativeChatUserMessageFold
|
||||
messageId={message.id}
|
||||
markdown={markdown}
|
||||
onRefolded={onRefolded}
|
||||
>
|
||||
<NativeChatMarkdown
|
||||
rowKey={message.id}
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</NativeChatUserMessageFold>
|
||||
</>
|
||||
) : (
|
||||
<NativeChatImageAttachments
|
||||
blocks={prose}
|
||||
runtimeContext={runtimeContext}
|
||||
enablePreview={runtimeContext !== undefined}
|
||||
/>
|
||||
<NativeChatUserMessageFold
|
||||
messageId={message.id}
|
||||
markdown={markdown}
|
||||
onRefolded={onRefolded}
|
||||
>
|
||||
<NativeChatMarkdown
|
||||
rowKey={message.id}
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</NativeChatUserMessageFold>
|
||||
</>
|
||||
) : (
|
||||
<NativeChatImageAttachments
|
||||
blocks={prose}
|
||||
runtimeContext={runtimeContext}
|
||||
enablePreview={runtimeContext !== undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.sentAs === 'goal' ? (
|
||||
<div className="flex items-center gap-1 text-xs text-chat-foreground-faint">
|
||||
<Goal className="size-3" aria-hidden />
|
||||
<span>{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<UserMessageMeta
|
||||
markdown={markdown}
|
||||
timestamp={message.timestamp}
|
||||
sending={sending}
|
||||
{...(rewind ? { rewind: { itemId: message.id, surface: rewind } } : {})}
|
||||
/>
|
||||
{!from ? (
|
||||
<UserMessageMeta
|
||||
markdown={markdown}
|
||||
timestamp={message.timestamp}
|
||||
sending={sending}
|
||||
{...(rewind ? { rewind: { itemId: message.id, surface: rewind } } : {})}
|
||||
/>
|
||||
) : null}
|
||||
{deliveryNotice?.text !== undefined ? (
|
||||
<div className="flex max-w-[85%] items-center gap-2 text-2xs text-destructive/80">
|
||||
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
|
||||
|
||||
+6
-6
@@ -174,7 +174,7 @@ it('uses the recipient host when identical session text is published by two host
|
||||
}
|
||||
})
|
||||
render(<NativeChatAgentMessageSenders from={source()} chatWorktreeId={RECIPIENT} />)
|
||||
expect(screen.getByRole('button', { name: 'Remote sender' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote sender')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a chat sender by its current rename', () => {
|
||||
@@ -183,11 +183,11 @@ it('shows a chat sender by its current rename', () => {
|
||||
const status = feed.snapshot
|
||||
expect(feed.names.has(ROOT)).toBe(false)
|
||||
render(<NativeChatAgentMessageSenders from={source()} chatWorktreeId={RECIPIENT} />)
|
||||
expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Original sender')).toBeInTheDocument()
|
||||
act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: null }]))
|
||||
expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Original sender')).toBeInTheDocument()
|
||||
act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: 'Renamed sender' }]))
|
||||
expect(screen.getByRole('button', { name: 'Renamed sender' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Renamed sender')).toBeInTheDocument()
|
||||
expect(feed.snapshot).toBe(status)
|
||||
expect(status.get(ROOT)?.conversationName).toBeUndefined()
|
||||
})
|
||||
@@ -211,14 +211,14 @@ it.each([false, true])(
|
||||
})
|
||||
const from = source(true)
|
||||
render(<NativeChatAgentMessageSenders from={from} chatWorktreeId={RECIPIENT} queued={queued} />)
|
||||
expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Original sender')).toBeInTheDocument()
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: { [PANE]: row({ key: 'session_id', id: 'provider-b' }) },
|
||||
tabsByWorktree: { [SENDER]: [{ ...terminal, customTitle: 'Renamed B' }] }
|
||||
})
|
||||
})
|
||||
const link = screen.getByRole('button', { name: 'Original sender' })
|
||||
const link = screen.getByText('Original sender')
|
||||
expect(link).toBeInTheDocument()
|
||||
fireEvent.click(link)
|
||||
expect(openAgentMessageSender).toHaveBeenCalledWith(from, from.senders[0], RECIPIENT)
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeUnifiedTab } from '@/store/slices/store-test-helpers'
|
||||
import { findAgentMessageSenderChatTab } from './native-chat-agent-message-sender-label'
|
||||
|
||||
const initial = useAppStore.getState()
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'workspace',
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initial, true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([100, 1000])(
|
||||
'shares host-qualified tab collision scans for %i distinct senders and repeated cards',
|
||||
(n) => {
|
||||
const tab = (id: number, host: 'local' | 'runtime:remote') =>
|
||||
makeUnifiedTab({
|
||||
id: `${host}-${id}`,
|
||||
entityId: `session-${id}`,
|
||||
worktreeId: 'workspace',
|
||||
groupId: 'g',
|
||||
contentType: 'agent-session',
|
||||
executionHostId: host
|
||||
})
|
||||
let tabVisits = 0
|
||||
const bucket = Array.from({ length: n }, (_, i) => [
|
||||
tab(i, 'local'),
|
||||
tab(i, 'runtime:remote')
|
||||
]).flat()
|
||||
bucket.forEach((value, i) =>
|
||||
Object.defineProperty(bucket, i, {
|
||||
get() {
|
||||
tabVisits++
|
||||
return value
|
||||
}
|
||||
})
|
||||
)
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { workspace: bucket } })
|
||||
const state = useAppStore.getState()
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
expect(findAgentMessageSenderChatTab(state, `session-${i}`, 'local')?.id).toBe(`local-${i}`)
|
||||
}
|
||||
}
|
||||
// Workspace/session index, per-bucket session index, and qualified collision index each walk 2N tabs once.
|
||||
expect(tabVisits).toBe(n * 6)
|
||||
tabVisits = 0
|
||||
useAppStore.setState({ activeWorkspaceExecutionHostId: 'runtime:remote' })
|
||||
for (let i = 0; i < n; i++) {
|
||||
expect(
|
||||
findAgentMessageSenderChatTab(useAppStore.getState(), `session-${i}`, 'local')?.id
|
||||
).toBe(`local-${i}`)
|
||||
}
|
||||
expect(tabVisits).toBe(n * 2) // Only the owner-qualified index invalidates.
|
||||
}
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentMessageSenderLabel } from '../../../../shared/agent-session/agent-message-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name'
|
||||
import {
|
||||
structuredAgentSessionOwnerForTab,
|
||||
structuredAgentSessionOwnerInputs,
|
||||
resolveStructuredAgentSessionOwner,
|
||||
executionHostIdForStructuredTarget
|
||||
} from '@/runtime/structured-agent-session-owner'
|
||||
@@ -20,7 +22,46 @@ export function unnamedSenderLabel(): string {
|
||||
return translate('components.native-chat.agentMessage.unnamedSender', 'an agent')
|
||||
}
|
||||
|
||||
function findChatTab(
|
||||
const qualifiedTabs = new WeakMap<
|
||||
AppState['unifiedTabsByWorktree'],
|
||||
{
|
||||
inputs: unknown[]
|
||||
tabs: Map<string, Map<string | null, Tab>>
|
||||
}
|
||||
>()
|
||||
function qualifiedSenderTab(
|
||||
state: AppState,
|
||||
sessionId: string,
|
||||
host: string | null
|
||||
): Tab | undefined {
|
||||
const source = state.unifiedTabsByWorktree
|
||||
const inputs = structuredAgentSessionOwnerInputs(state)
|
||||
let cached = qualifiedTabs.get(source)
|
||||
if (!cached || inputs.some((value, index) => value !== cached?.inputs[index])) {
|
||||
const tabs = new Map<string, Map<string | null, Tab>>()
|
||||
for (const bucket of Object.values(source)) {
|
||||
for (const tab of bucket) {
|
||||
if (tab.contentType !== 'agent-session') {
|
||||
continue
|
||||
}
|
||||
const owner = structuredAgentSessionOwnerForTab(state, tab)
|
||||
let byHost = tabs.get(tab.entityId)
|
||||
if (!byHost) {
|
||||
byHost = new Map()
|
||||
tabs.set(tab.entityId, byHost)
|
||||
}
|
||||
if (!byHost.has(owner)) {
|
||||
byHost.set(owner, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = { inputs, tabs }
|
||||
qualifiedTabs.set(source, cached)
|
||||
}
|
||||
return cached.tabs.get(sessionId)?.get(host)
|
||||
}
|
||||
|
||||
export function findAgentMessageSenderChatTab(
|
||||
state: AppState,
|
||||
sessionId: string,
|
||||
executionHostId: string | null
|
||||
@@ -32,14 +73,7 @@ function findChatTab(
|
||||
return tab
|
||||
}
|
||||
// A workspace bucket can mirror identical session ids from two hosts.
|
||||
const qualified =
|
||||
tab &&
|
||||
tabsByWorktree[worktreeId]?.find(
|
||||
(candidate) =>
|
||||
candidate.contentType === 'agent-session' &&
|
||||
candidate.entityId === sessionId &&
|
||||
structuredAgentSessionOwnerForTab(state, candidate) === executionHostId
|
||||
)
|
||||
const qualified = tab && qualifiedSenderTab(state, sessionId, executionHostId)
|
||||
if (qualified) {
|
||||
return qualified
|
||||
}
|
||||
@@ -47,6 +81,18 @@ function findChatTab(
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function agentMessageSenderOwner(
|
||||
state: AppState,
|
||||
chatWorktreeId: string | null,
|
||||
target?: RuntimeClientTarget
|
||||
): string | null {
|
||||
return target
|
||||
? executionHostIdForStructuredTarget(target)
|
||||
: chatWorktreeId && getKnownExecutionHostIdForWorktree(state, chatWorktreeId)
|
||||
? resolveStructuredAgentSessionOwner(state, chatWorktreeId)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Live chat names follow the sender's chat on the receiving chat's host; CLI names stay as recorded. */
|
||||
export function useAgentMessageSenderLabel(
|
||||
sender: AgentMessageSender,
|
||||
@@ -55,17 +101,19 @@ export function useAgentMessageSenderLabel(
|
||||
): string {
|
||||
const { address, orcaSessionId } = sender.party
|
||||
const local = !address.startsWith('dispatch:')
|
||||
const owner = useAppStore((state) =>
|
||||
target
|
||||
? executionHostIdForStructuredTarget(target)
|
||||
: chatWorktreeId && getKnownExecutionHostIdForWorktree(state, chatWorktreeId)
|
||||
? resolveStructuredAgentSessionOwner(state, chatWorktreeId)
|
||||
: null
|
||||
)
|
||||
const owner = useAppStore((state) => agentMessageSenderOwner(state, chatWorktreeId, target))
|
||||
const chatTab = useAppStore((state) =>
|
||||
local && orcaSessionId ? findChatTab(state, orcaSessionId, owner) : undefined
|
||||
local && owner && orcaSessionId
|
||||
? findAgentMessageSenderChatTab(state, orcaSessionId, owner)
|
||||
: undefined
|
||||
)
|
||||
const conversationName = useStructuredChatTabConversationName(chatTab)
|
||||
const ownName = chatTab ? chatTab.customLabel?.trim() || conversationName : null
|
||||
return ownName || sender.name || chatTab?.label.trim() || unnamedSenderLabel()
|
||||
return agentMessageSenderLabel({
|
||||
localChat: Boolean(local && chatTab),
|
||||
customLabel: chatTab?.customLabel,
|
||||
conversationName,
|
||||
snapshotName: sender.name,
|
||||
tabLabel: chatTab?.label,
|
||||
fallback: unnamedSenderLabel()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
createWorktreeAgentRowsCache,
|
||||
startWorktreeAgentRowsCachePass,
|
||||
selectWorktreeAgentRowsCached
|
||||
} from '@/components/dashboard/worktree-agent-rows-cache'
|
||||
import { createWorktreeAgentFreshnessSelector } from '@/components/sidebar/worktree-agent-freshness-selector'
|
||||
import { selectRuntimeAgentOrchestrationForWorktree } from '@/components/sidebar/worktree-agent-row-selectors'
|
||||
import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state'
|
||||
import { resolveRowProgramStatus } from '@/lib/program-status/agent-row-presentation'
|
||||
import {
|
||||
agentMessageSenderView,
|
||||
type AgentMessageSenderView
|
||||
} from '../../../../shared/agent-session/agent-message-presentation'
|
||||
|
||||
const EMPTY_VIEW: AgentMessageSenderView = Object.freeze({})
|
||||
export type SenderLocation = {
|
||||
host: string
|
||||
worktreeId: string
|
||||
paneKey: string
|
||||
agentType?: AgentMessageSenderView['agentType']
|
||||
}
|
||||
type LocateSender = (state: AppState) => SenderLocation | undefined
|
||||
|
||||
function equalView(a: AgentMessageSenderView, b: AgentMessageSenderView): boolean {
|
||||
return a.agentType === b.agentType && a.dot === b.dot && a.progress === b.progress
|
||||
}
|
||||
|
||||
function createSource(worktreeId: string) {
|
||||
const cache = createWorktreeAgentRowsCache()
|
||||
const freshness = createWorktreeAgentFreshnessSelector(worktreeId)
|
||||
let snapshot: AppState | undefined
|
||||
let rows: ReturnType<typeof selectWorktreeAgentRowsCached> | undefined
|
||||
let views = new Map<string, AgentMessageSenderView>()
|
||||
return {
|
||||
leases: 0,
|
||||
dispose() {
|
||||
snapshot = undefined
|
||||
rows = undefined
|
||||
views.clear()
|
||||
cache.byWorktree.clear()
|
||||
startWorktreeAgentRowsCachePass(cache)
|
||||
},
|
||||
read(state: AppState, paneKey: string): AgentMessageSenderView | undefined {
|
||||
if (snapshot !== state) {
|
||||
snapshot = state
|
||||
startWorktreeAgentRowsCachePass(cache)
|
||||
const next = selectWorktreeAgentRowsCached({
|
||||
state,
|
||||
worktreeId,
|
||||
cache,
|
||||
now: Date.now(),
|
||||
generation: freshness(state),
|
||||
orchestration: selectRuntimeAgentOrchestrationForWorktree(state, worktreeId)
|
||||
})
|
||||
if (rows !== next) {
|
||||
rows = next
|
||||
const indexed = new Map<string, AgentMessageSenderView>()
|
||||
for (const row of next) {
|
||||
if (indexed.has(row.paneKey)) {
|
||||
continue
|
||||
}
|
||||
const { presentation, dotStateOverride } = resolveRowProgramStatus(row)
|
||||
const value = {
|
||||
agentType: row.agentType,
|
||||
dot: dotStateOverride ?? agentRowDisplayDotState(row),
|
||||
progress: presentation.progress
|
||||
}
|
||||
const previous = views.get(row.paneKey)
|
||||
indexed.set(row.paneKey, previous && equalView(previous, value) ? previous : value)
|
||||
}
|
||||
views = indexed
|
||||
}
|
||||
}
|
||||
return views.get(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Source = ReturnType<typeof createSource>
|
||||
const subscribedSources = new Map<string, Source>()
|
||||
// Uncommitted renders have no registry lease; their snapshot and derived rows can be collected.
|
||||
const transientSources = new WeakMap<AppState, Map<string, Source>>()
|
||||
function sourceKey(location: SenderLocation): string {
|
||||
return JSON.stringify([location.host, location.worktreeId])
|
||||
}
|
||||
function readSource(state: AppState, location: SenderLocation): Source {
|
||||
const key = sourceKey(location)
|
||||
const subscribed = subscribedSources.get(key)
|
||||
if (subscribed) {
|
||||
return subscribed
|
||||
}
|
||||
let sources = transientSources.get(state)
|
||||
if (!sources) {
|
||||
sources = new Map()
|
||||
transientSources.set(state, sources)
|
||||
}
|
||||
let source = sources.get(key)
|
||||
if (!source) {
|
||||
source = createSource(location.worktreeId)
|
||||
sources.set(key, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/** Share the sidebar projection before notifying cards; only subscription leases retain a source. */
|
||||
export function useAgentMessageSenderSource(locate: LocateSender): AgentMessageSenderView {
|
||||
const selected = useMemo(() => {
|
||||
let previous = EMPTY_VIEW
|
||||
return (state: AppState) => {
|
||||
const location = locate(state)
|
||||
const next = location
|
||||
? agentMessageSenderView(
|
||||
readSource(state, location).read(state, location.paneKey),
|
||||
location.agentType
|
||||
)
|
||||
: EMPTY_VIEW
|
||||
if (!equalView(previous, next)) {
|
||||
previous = next
|
||||
}
|
||||
return previous
|
||||
}
|
||||
}, [locate])
|
||||
const subscribe = useCallback(
|
||||
(notify: () => void) => {
|
||||
let lease: { key: string; source: Source } | undefined
|
||||
const release = () => {
|
||||
if (lease && --lease.source.leases === 0) {
|
||||
subscribedSources.delete(lease.key)
|
||||
lease.source.dispose()
|
||||
}
|
||||
lease = undefined
|
||||
}
|
||||
const update = () => {
|
||||
const state = useAppStore.getState()
|
||||
const location = locate(state)
|
||||
const key = location ? sourceKey(location) : undefined
|
||||
if (lease?.key !== key) {
|
||||
release()
|
||||
if (location && key) {
|
||||
const source = readSource(state, location)
|
||||
transientSources.get(state)?.delete(key)
|
||||
source.leases++
|
||||
subscribedSources.set(key, source)
|
||||
lease = { key, source }
|
||||
}
|
||||
}
|
||||
return selected(state)
|
||||
}
|
||||
let value = update()
|
||||
const unsubscribe = useAppStore.subscribe(() => {
|
||||
const next = update()
|
||||
if (next !== value) {
|
||||
value = next
|
||||
notify()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
release()
|
||||
}
|
||||
},
|
||||
[locate, selected]
|
||||
)
|
||||
const getSnapshot = useCallback(() => selected(useAppStore.getState()), [selected])
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import {
|
||||
useAgentMessageSenderSource,
|
||||
type SenderLocation
|
||||
} from './native-chat-agent-message-sender-source'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { structuredAgentSessionOwnerInputs } from '@/runtime/structured-agent-session-owner'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { AgentMessageSender } from '../../../../shared/agent-session/agent-session-message-source'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import {
|
||||
agentMessageSenderOwner,
|
||||
findAgentMessageSenderChatTab
|
||||
} from './native-chat-agent-message-sender-label'
|
||||
|
||||
const handleIndexes = new WeakMap<
|
||||
object,
|
||||
{ inputs: unknown[]; index: Map<string, Map<string | null, AgentStatusEntry[]>> }
|
||||
>()
|
||||
function byHandle(
|
||||
state: AppState,
|
||||
source: AppState['agentStatusByPaneKey'] | AppState['retainedAgentsByPaneKey']
|
||||
) {
|
||||
const inputs = structuredAgentSessionOwnerInputs(state)
|
||||
let cached = handleIndexes.get(source)
|
||||
if (!cached || inputs.some((value, index) => value !== cached?.inputs[index])) {
|
||||
const index = new Map<string, Map<string | null, AgentStatusEntry[]>>()
|
||||
const owners = new Map<string, string | null>()
|
||||
for (const value of Object.values(source)) {
|
||||
const entry = 'entry' in value ? value.entry : value
|
||||
if (!entry.terminalHandle || !entry.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!owners.has(entry.worktreeId)) {
|
||||
owners.set(entry.worktreeId, getKnownExecutionHostIdForWorktree(state, entry.worktreeId))
|
||||
}
|
||||
const owner = owners.get(entry.worktreeId) ?? null
|
||||
let byHost = index.get(entry.terminalHandle)
|
||||
if (!byHost) {
|
||||
byHost = new Map()
|
||||
index.set(entry.terminalHandle, byHost)
|
||||
}
|
||||
const entries = byHost.get(owner) ?? []
|
||||
entries.push(entry)
|
||||
byHost.set(owner, entries)
|
||||
}
|
||||
cached = { inputs, index }
|
||||
handleIndexes.set(source, cached)
|
||||
}
|
||||
return cached.index
|
||||
}
|
||||
|
||||
export function useAgentMessageSenderView(
|
||||
sender: AgentMessageSender | undefined,
|
||||
chatWorktreeId: string | null,
|
||||
target?: RuntimeClientTarget
|
||||
) {
|
||||
const address = sender?.party.address
|
||||
const sessionId = sender?.party.orcaSessionId
|
||||
const handle = sender?.party.terminalHandle
|
||||
const targetKind = target?.kind
|
||||
const environmentId = target?.kind === 'environment' ? target.environmentId : undefined
|
||||
const stableTarget = useMemo<RuntimeClientTarget | undefined>(
|
||||
() =>
|
||||
targetKind === 'local'
|
||||
? { kind: 'local' }
|
||||
: environmentId !== undefined
|
||||
? { kind: 'environment', environmentId }
|
||||
: undefined,
|
||||
[targetKind, environmentId]
|
||||
)
|
||||
const locate = useCallback(
|
||||
(state: AppState): SenderLocation | undefined => {
|
||||
if (!address || address.startsWith('dispatch:')) {
|
||||
return undefined
|
||||
}
|
||||
const owner = agentMessageSenderOwner(state, chatWorktreeId, stableTarget)
|
||||
if (!owner) {
|
||||
return undefined
|
||||
}
|
||||
const chatTab = sessionId ? findAgentMessageSenderChatTab(state, sessionId, owner) : undefined
|
||||
let paneKey = chatTab && structuredAgentSessionPaneKey(chatTab.id, chatTab.entityId)
|
||||
if (!paneKey && handle) {
|
||||
for (const source of [state.agentStatusByPaneKey, state.retainedAgentsByPaneKey]) {
|
||||
const matches = byHandle(state, source).get(handle)?.get(owner) ?? []
|
||||
if (matches.length) {
|
||||
paneKey = matches.length === 1 ? matches[0].paneKey : undefined
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const worktreeId =
|
||||
chatTab?.worktreeId ??
|
||||
(paneKey
|
||||
? (state.agentStatusByPaneKey[paneKey]?.worktreeId ??
|
||||
state.retainedAgentsByPaneKey[paneKey]?.worktreeId)
|
||||
: undefined)
|
||||
return worktreeId && paneKey
|
||||
? { host: owner, worktreeId, paneKey, agentType: chatTab?.agentSessionAgent }
|
||||
: undefined
|
||||
},
|
||||
[address, sessionId, handle, chatWorktreeId, stableTarget]
|
||||
)
|
||||
return useAgentMessageSenderSource(locate)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentMessageTranscriptText } from '../../../../shared/agent-session/agent-message-display-text'
|
||||
import { nativeChatMessageCopyText } from '../../../../shared/native-chat-message-copy-text'
|
||||
import { nativeChatStoppedBeforeStartLabel } from './native-chat-stopped-before-start-row'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
@@ -9,6 +10,8 @@ export function nativeChatSlotCopyText(
|
||||
): string | null {
|
||||
const slot = slots.find((entry) => entry.kind === 'message' && entry.message.id === messageId)
|
||||
return slot?.kind === 'message'
|
||||
? nativeChatMessageCopyText(slot.message, nativeChatStoppedBeforeStartLabel())
|
||||
? slot.message.from && slot.message.role === 'user'
|
||||
? agentMessageTranscriptText(slot.message.blocks, slot.message.from)
|
||||
: nativeChatMessageCopyText(slot.message, nativeChatStoppedBeforeStartLabel())
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import type * as SonnerModule from 'sonner'
|
||||
import type * as StructuredSessionClient from '@/runtime/structured-agent-session-client'
|
||||
import type * as RendererI18n from '@/i18n/i18n'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionQueuedMessage,
|
||||
@@ -44,11 +47,28 @@ const hoistedRpc = vi.hoisted(() =>
|
||||
)
|
||||
)
|
||||
export const rpc = hoistedRpc
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
const toastError = vi.hoisted(() => vi.fn<typeof SonnerModule.toast.error>())
|
||||
vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof StructuredSessionClient>()),
|
||||
callStructuredAgentSession: hoistedRpc
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('sonner', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof SonnerModule>()
|
||||
return {
|
||||
...original,
|
||||
toast: Object.assign(
|
||||
function (this: unknown, ...args: Parameters<typeof original.toast>) {
|
||||
return original.toast.apply(this, args)
|
||||
},
|
||||
original.toast,
|
||||
{ error: toastError }
|
||||
)
|
||||
}
|
||||
})
|
||||
vi.mock('@/i18n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RendererI18n>()),
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
export const SESSION = 'folder-session'
|
||||
export const SCOPE = 'pane-draft'
|
||||
|
||||
@@ -19730,7 +19730,12 @@
|
||||
},
|
||||
"agentMessage": {
|
||||
"messageFrom": "Message from",
|
||||
"unnamedSender": "an agent"
|
||||
"unnamedSender": "an agent",
|
||||
"goToAgent": "Go to agent",
|
||||
"messageFromCoordinator": "Message from your coordinator",
|
||||
"showMore": "Show more",
|
||||
"taskFromCoordinator": "Task from your coordinator",
|
||||
"updateFromWorker": "Update from your worker"
|
||||
},
|
||||
"goal": {
|
||||
"placeholder": "Describe your goal, define measurable outcomes for best results",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { agentMessageDisplayText, agentMessageTranscriptText } from './agent-message-display-text'
|
||||
import type { AgentMessageSource } from './agent-session-message-source'
|
||||
import type { NativeChatBlock } from '../native-chat-types'
|
||||
import * as grammar from '../orca-dispatch-status-prompt'
|
||||
|
||||
const task: AgentMessageSource = {
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' }
|
||||
}
|
||||
const prefix = `${grammar.ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\r\nInstructions\r\n=== TASK ===\r\n`
|
||||
|
||||
it('keeps exact task suffixes and canonical blocks, with paragraph fallback for ineligible inputs', () => {
|
||||
for (const suffix of ['', ' Task 🐋\r\n\r\n=== TASK ===\ntrailing ']) {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'text', text: prefix + suffix },
|
||||
{ type: 'image-ref', path: '/image.png' }
|
||||
]
|
||||
const original = structuredClone(blocks)
|
||||
expect(agentMessageDisplayText(blocks, task)).toBe(suffix)
|
||||
expect(agentMessageTranscriptText(blocks, task)).toBe(suffix)
|
||||
expect(agentMessageDisplayText(blocks)).toBeUndefined()
|
||||
expect(blocks).toEqual(original)
|
||||
}
|
||||
for (const blocks of [
|
||||
[{ type: 'text', text: 'ordinary prose' }],
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: `${grammar.ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\n${'x'.repeat(24_576)}\n=== TASK ===\nlate`
|
||||
}
|
||||
]
|
||||
] satisfies NativeChatBlock[][]) {
|
||||
expect(agentMessageDisplayText(blocks, task)).toBeUndefined()
|
||||
expect(agentMessageTranscriptText(blocks, task)).toBe(
|
||||
blocks.map((block) => (block.type === 'text' ? block.text : '')).join('\n\n')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([100, 1000])(
|
||||
'parses %i real task inputs once and invalidates immutable edited blocks',
|
||||
(n) => {
|
||||
const parse = vi.spyOn(grammar, 'orcaDispatchTaskBodyOffset')
|
||||
const suffix = 'task '.repeat(6000)
|
||||
const inputs = Array.from({ length: n }, (): NativeChatBlock[] => [
|
||||
{ type: 'text', text: prefix + suffix }
|
||||
])
|
||||
try {
|
||||
for (const blocks of inputs) {
|
||||
expect(agentMessageDisplayText(blocks, task)).toBe(suffix)
|
||||
}
|
||||
expect(parse).toHaveBeenCalledTimes(n)
|
||||
parse.mockClear()
|
||||
for (const blocks of inputs) {
|
||||
expect(agentMessageDisplayText(blocks, task)).toBe(suffix)
|
||||
}
|
||||
expect(parse).not.toHaveBeenCalled()
|
||||
for (const _blocks of inputs) {
|
||||
expect(
|
||||
agentMessageDisplayText([{ type: 'text', text: `${prefix}${suffix}edited` }], task)
|
||||
).toBe(`${suffix}edited`)
|
||||
}
|
||||
expect(parse).toHaveBeenCalledTimes(n)
|
||||
} finally {
|
||||
parse.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentMessageSource } from './agent-session-message-source'
|
||||
import type { NativeChatBlock } from '../native-chat-types'
|
||||
import { nativeChatProseToMarkdown } from '../native-chat/transcript/native-chat-prose'
|
||||
import { orcaDispatchTaskBodyOffset } from '../orca-dispatch-status-prompt'
|
||||
|
||||
const taskBodies = new WeakMap<readonly NativeChatBlock[], string | undefined>()
|
||||
|
||||
/** Display only: providers, queue editors and context readers retain the original input. */
|
||||
export function agentMessageDisplayText(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
from?: AgentMessageSource
|
||||
): string | undefined {
|
||||
if (from?.orchestration?.message !== 'task') {
|
||||
return undefined
|
||||
}
|
||||
if (taskBodies.has(blocks)) {
|
||||
return taskBodies.get(blocks)
|
||||
}
|
||||
if (blocks.some((block) => block.type !== 'text' && block.type !== 'image-ref')) {
|
||||
taskBodies.set(blocks, undefined)
|
||||
return undefined
|
||||
}
|
||||
const texts = blocks.filter((block) => block.type === 'text')
|
||||
const text = texts.length === 1 ? texts[0].text : undefined
|
||||
const offset = text === undefined ? undefined : orcaDispatchTaskBodyOffset(text)
|
||||
const body = offset === undefined ? undefined : text?.slice(offset)
|
||||
taskBodies.set(blocks, body)
|
||||
return body
|
||||
}
|
||||
|
||||
export function agentMessageTranscriptText(
|
||||
blocks: NativeChatBlock[],
|
||||
from?: AgentMessageSource
|
||||
): string {
|
||||
return agentMessageDisplayText(blocks, from) ?? nativeChatProseToMarkdown(blocks)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AgentMessageSender, AgentMessageSource } from './agent-session-message-source'
|
||||
import type { AgentStatusEntry } from '../agent-status-types'
|
||||
|
||||
export const AGENT_MESSAGE_FOLDED_LINES = 3
|
||||
export type AgentMessageHeading = 'task' | 'coordinator' | 'worker' | 'generic'
|
||||
export type AgentMessageSenderView = Readonly<{
|
||||
agentType?: AgentStatusEntry['agentType']
|
||||
progress?: number
|
||||
dot?:
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'blocked'
|
||||
| 'waiting'
|
||||
| 'interrupted'
|
||||
| 'failed'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'unverifiable'
|
||||
| 'unconfirmed'
|
||||
| 'permission'
|
||||
}>
|
||||
|
||||
export function agentMessageHeading(from: AgentMessageSource): AgentMessageHeading {
|
||||
if (from.orchestration?.message === 'task') {
|
||||
return 'task'
|
||||
}
|
||||
if (from.orchestration?.message === 'mail-notice' && from.senders.length > 0) {
|
||||
if (from.senders.every((sender) => sender.relation === 'coordinator')) {
|
||||
return 'coordinator'
|
||||
}
|
||||
if (from.senders.every((sender) => sender.relation === 'worker')) {
|
||||
return 'worker'
|
||||
}
|
||||
}
|
||||
return 'generic'
|
||||
}
|
||||
|
||||
export function agentMessageSenderLabel(facts: {
|
||||
localChat: boolean
|
||||
customLabel?: string | null
|
||||
conversationName?: string | null
|
||||
snapshotName?: string | null
|
||||
tabLabel?: string | null
|
||||
fallback: string
|
||||
}): string {
|
||||
return (
|
||||
(facts.localChat && (facts.customLabel?.trim() || facts.conversationName)) ||
|
||||
facts.snapshotName ||
|
||||
facts.tabLabel?.trim() ||
|
||||
facts.fallback
|
||||
)
|
||||
}
|
||||
|
||||
export function agentMessageSenderCanOpen(
|
||||
sender: AgentMessageSender,
|
||||
hasWorkspace: boolean
|
||||
): boolean {
|
||||
return hasWorkspace && !sender.party.address.startsWith('dispatch:')
|
||||
}
|
||||
|
||||
export function agentMessagePrimarySender(
|
||||
from: AgentMessageSource
|
||||
): AgentMessageSender | undefined {
|
||||
return from.senders[0]
|
||||
}
|
||||
|
||||
export function agentMessageSenderView(
|
||||
row: AgentMessageSenderView | undefined,
|
||||
chatType: AgentMessageSenderView['agentType']
|
||||
): AgentMessageSenderView {
|
||||
return row ?? { agentType: chatType }
|
||||
}
|
||||
|
||||
export function agentMessageOverflows(height: number, lineHeight: number): boolean {
|
||||
return height > lineHeight * AGENT_MESSAGE_FOLDED_LINES + 1
|
||||
}
|
||||
|
||||
export function agentMessageFoldLabel(open: boolean): 'expanded' | 'collapsed' {
|
||||
return open ? 'expanded' : 'collapsed'
|
||||
}
|
||||
|
||||
export function agentMessageCanCopy(text: string): boolean {
|
||||
return text.length > 0
|
||||
}
|
||||
@@ -44,6 +44,11 @@ describe("a message's sender, read through its one reader", () => {
|
||||
orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'ctx_1' }
|
||||
}
|
||||
expect(readAgentMessageSource(structuredClone(task))).toEqual(task)
|
||||
const parsed = readAgentMessageSource({
|
||||
...task,
|
||||
senders: FROM.senders.map((sender) => ({ ...sender, relation: 'future' }))
|
||||
})
|
||||
expect(parsed).toEqual(task)
|
||||
})
|
||||
|
||||
it('keeps an agent an agent when its orchestration kind is newer than this build', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AgentMessageSender = Readonly<{
|
||||
party: Omit<OrchestrationPartyIdentity, 'paneKey'>
|
||||
/** What Orca called the sender when it wrote, kept for when it is gone; null when it had none. */
|
||||
name: string | null
|
||||
relation?: 'worker' | 'coordinator' | 'none'
|
||||
}>
|
||||
|
||||
/** One orchestration message a notice points at: its record, and its sender's `senders` address. */
|
||||
@@ -80,6 +81,7 @@ const senderSchema = z.object({
|
||||
terminalHandle: z.string().nullable(),
|
||||
orcaSessionId: orcaSessionIdSchema.nullable()
|
||||
}),
|
||||
relation: salvagedOptional('relation', z.enum(['worker', 'coordinator', 'none'])),
|
||||
name: z.unknown().transform(agentMessageSenderName)
|
||||
})
|
||||
|
||||
|
||||
@@ -119,6 +119,36 @@ export function findOrcaDispatchTaskMarkerIndex(value: string): number {
|
||||
: value.indexOf(ORCA_DISPATCH_STATUS_TASK_MARKER)
|
||||
}
|
||||
|
||||
/** Offset into the original task input; detection is bounded, the authored suffix is not. */
|
||||
export function orcaDispatchTaskBodyOffset(value: string): number | undefined {
|
||||
const start = findOrcaDispatchPreambleStart(value)
|
||||
if (start === -1) {
|
||||
return undefined
|
||||
}
|
||||
const scanEnd = Math.min(value.length, ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT)
|
||||
const scan = value.slice(start, scanEnd)
|
||||
const marker = findOrcaDispatchTaskMarkerIndex(scan)
|
||||
if (marker === -1) {
|
||||
return undefined
|
||||
}
|
||||
let offset = start + marker + ORCA_DISPATCH_STATUS_TASK_MARKER.length
|
||||
// A marker at the scan bound must still end its original delimiter line.
|
||||
if (
|
||||
offset < value.length &&
|
||||
!isLineBreak(value.charCodeAt(offset)) &&
|
||||
(scan.includes('\n') || scan.includes('\r'))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (value.charCodeAt(offset) === 13) {
|
||||
offset++
|
||||
}
|
||||
if (value.charCodeAt(offset) === 10) {
|
||||
offset++
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
function isLineBreak(code: number): boolean {
|
||||
return code === 10 || code === 13
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Derive hold labels on the client; the wire carries queue state, not presentation wording.
|
||||
|
||||
import { agentMessageDisplayText } from './agent-session/agent-message-display-text'
|
||||
import {
|
||||
readWholeAgentSessionFailureFact,
|
||||
type AgentSessionFailureFact,
|
||||
@@ -44,6 +45,7 @@ export type QueuedMessageCard = {
|
||||
messageId: string
|
||||
position: number
|
||||
text: string
|
||||
displayText?: string
|
||||
/** The draft's images, which an edit of its text keeps. */
|
||||
images?: readonly NativeChatImageRefBlock[]
|
||||
state: 'waiting' | 'returned'
|
||||
@@ -100,6 +102,13 @@ export function projectQueuedMessageCards(
|
||||
messageId: message.messageId,
|
||||
position: message.position,
|
||||
text: queuedMessageCardText(message.body),
|
||||
...(from
|
||||
? {
|
||||
displayText:
|
||||
agentMessageDisplayText(message.body.blocks, from) ??
|
||||
queuedMessageCardText(message.body)
|
||||
}
|
||||
: {}),
|
||||
...queuedMessageCardImages(message.body),
|
||||
state: message.state,
|
||||
hold,
|
||||
@@ -171,12 +180,13 @@ export function newestSteerableQueuedMessageCard(
|
||||
|
||||
/** The card's line: its text, or for a card of images alone, the image's name or their count. */
|
||||
export function queuedMessageCardLine(
|
||||
card: Pick<QueuedMessageCard, 'text' | 'images'>,
|
||||
card: Pick<QueuedMessageCard, 'text' | 'images' | 'displayText'>,
|
||||
copy: NativeChatImageLabelCopy = NATIVE_CHAT_IMAGE_LABEL_ENGLISH
|
||||
): string {
|
||||
const images = card.images ?? []
|
||||
if (card.text.trim() || images.length === 0) {
|
||||
return card.text
|
||||
const text = card.displayText ?? card.text
|
||||
if (text.trim() || images.length === 0) {
|
||||
return text
|
||||
}
|
||||
return images.length === 1
|
||||
? nativeChatImageLabel(images[0], copy)
|
||||
|
||||
Reference in New Issue
Block a user