mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix(native-chat): suppress provider user echoes in Claude and Codex (#19136)
* fix(native-chat): keep provider user echoes out of the conversation * fix(native-chat): retain input beside Codex skill context --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
c7bcfa750a
commit
ade9718557
@@ -47,6 +47,92 @@ const BASE64_IMAGE = {
|
||||
}
|
||||
|
||||
describe('Claude message content parts', () => {
|
||||
it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
|
||||
'consumes %s skill context without a user bubble, fallback, or new turn',
|
||||
(flag) => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const event = userMessageWith({ type: 'text', text: '# Skill instructions' })
|
||||
translator.handle({ ...event, message: { ...event.message, [flag]: true } })
|
||||
expect(state.items).toEqual([])
|
||||
expect(state.sink.publish).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps tool results in an injected skill message', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const event = userMessageWith({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'skill-call',
|
||||
content: 'Skill loaded'
|
||||
})
|
||||
translator.handle({ ...event, message: { ...event.message, isMeta: true } })
|
||||
expect(state.items.map((item) => item.body)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'tool-call',
|
||||
state: 'completed',
|
||||
output: expect.objectContaining({ head: 'Skill loaded', truncated: false })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ content: '# Skill instructions' },
|
||||
{ content: [{ type: 'future_context', text: '# Skill instructions' }] },
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: '[Image: source: /tmp/pasted.png]' },
|
||||
{ type: 'text', text: '# Skill instructions' }
|
||||
]
|
||||
}
|
||||
])('does not surface injected content as text or a provider fallback: %j', ({ content }) => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const event = userMessageWith(null)
|
||||
translator.handle({
|
||||
...event,
|
||||
message: { ...event.message, isMeta: true, message: { role: 'user', content } }
|
||||
})
|
||||
expect(state.items).toEqual([])
|
||||
})
|
||||
|
||||
it('does not render user echoes even without metadata flags', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
for (const content of ['/example-skill', '# Skill instructions']) {
|
||||
const event = userMessageWith(null)
|
||||
translator.handle({
|
||||
...event,
|
||||
message: { ...event.message, message: { role: 'user', content } }
|
||||
})
|
||||
}
|
||||
expect(state.items.flatMap(({ body }) => (body.kind === 'message' ? body.blocks : []))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('silently consumes unmarked user context with unknown content parts', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const event = userMessageWith({ type: 'future_context', text: 'Expanded instructions' })
|
||||
translator.handle({ ...event, startsTurn: undefined })
|
||||
expect(state.items).toEqual([])
|
||||
expect(state.sink.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render injected image companions or start a turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const event = userMessageWith(null)
|
||||
const content = [{ type: 'text', text: '[Image: source: /tmp/pasted.png]' }]
|
||||
translator.handle({
|
||||
...event,
|
||||
message: { ...event.message, isMeta: true, message: { role: 'user', content } }
|
||||
})
|
||||
expect(state.items).toEqual([])
|
||||
})
|
||||
|
||||
it('does not leak a wire kind for a locally attached image', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
@@ -56,7 +142,7 @@ describe('Claude message content parts', () => {
|
||||
expect(providerRows(state.items)).toEqual([])
|
||||
})
|
||||
|
||||
it('still renders an image the CLI sends by url', () => {
|
||||
it('does not render echoed image URLs', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
@@ -67,21 +153,29 @@ describe('Claude message content parts', () => {
|
||||
expect(providerRows(state.items)).toEqual([])
|
||||
expect(
|
||||
state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : []))
|
||||
).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('says what is true for a content part it cannot render, not the wire kind', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } }))
|
||||
const event = userMessageWith(null)
|
||||
translator.handle({
|
||||
...event,
|
||||
message: {
|
||||
...event.message,
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'some_future_part', payload: { a: 1 } }] }
|
||||
}
|
||||
})
|
||||
|
||||
const rows = providerRows(state.items)
|
||||
expect(rows).toHaveLength(1)
|
||||
// The kind stays on the row for debugging, behind the disclosure.
|
||||
expect(rows[0].kind).toBe('message:user:content:some_future_part')
|
||||
expect(rows[0].kind).toBe('message:assistant:content:some_future_part')
|
||||
// ...but the visible text is a sentence, not the opcode.
|
||||
expect(rows[0].text).not.toContain('message:user:content')
|
||||
expect(rows[0].text).not.toContain('message:assistant:content')
|
||||
expect(rows[0].text.toLowerCase()).toContain('claude')
|
||||
})
|
||||
|
||||
@@ -89,9 +183,18 @@ describe('Claude message content parts', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' })
|
||||
)
|
||||
const event = userMessageWith(null)
|
||||
translator.handle({
|
||||
...event,
|
||||
message: {
|
||||
...event.message,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'some_future_part', message: 'the server refused the upload' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(providerRows(state.items)[0].text).toBe('the server refused the upload')
|
||||
})
|
||||
|
||||
@@ -49,6 +49,28 @@ function userReplayFrame(uuid: string, text: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe('Claude structured dispatch image limits', () => {
|
||||
it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
|
||||
'does not acknowledge a dispatch with %s context even when the client uuid matches',
|
||||
async (flag) => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/example' }]) },
|
||||
1000
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const sentUuid = session.dispatchWaiters[0]!.sentUuid
|
||||
const replay = userReplayFrame(sentUuid, '/example')
|
||||
expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true })).toBe(false)
|
||||
expect(session.dispatchWaiters).toHaveLength(1)
|
||||
expect(resolveClaudeReplayWaiter(session, replay)).toBe(true)
|
||||
await expect(dispatched).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: sentUuid }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('recovers the active identity when a timed-out replay arrives late', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ClaudeMessageEnvelope = {
|
||||
/** Messages API id shared by every frame of one streamed assistant message. */
|
||||
messageId: string | null
|
||||
parentToolUseId: string | null
|
||||
isInjectedUserTurn?: boolean
|
||||
}
|
||||
|
||||
export type ClaudeToolUse = { id: string; name: string; input: unknown }
|
||||
@@ -42,12 +43,16 @@ export function readClaudeMessageEnvelope(
|
||||
const sessionId = claudeText(frame.session_id)
|
||||
const uuid = claudeText(frame.uuid)
|
||||
const role = message?.role
|
||||
const isInjectedUserTurn =
|
||||
frame.type === 'user' &&
|
||||
(frame.isMeta === true || frame.isSynthetic === true || frame.isCompactSummary === true)
|
||||
return sessionId && uuid && (role === 'assistant' || role === 'user')
|
||||
? {
|
||||
sessionId,
|
||||
uuid,
|
||||
role,
|
||||
content: messageContent(message?.content),
|
||||
isInjectedUserTurn,
|
||||
messageId: claudeText(message?.id),
|
||||
parentToolUseId: claudeText(frame.parent_tool_use_id)
|
||||
}
|
||||
@@ -93,6 +98,9 @@ export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournal
|
||||
}
|
||||
|
||||
export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
|
||||
if (envelope.isInjectedUserTurn) {
|
||||
return false
|
||||
}
|
||||
return envelope.content.some((value) => {
|
||||
const part = claudeRecord(value)
|
||||
return part !== null && part.type !== 'tool_result'
|
||||
|
||||
@@ -342,10 +342,7 @@ describe('Claude structured journal translation', () => {
|
||||
state.items.flatMap((item) =>
|
||||
item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : []
|
||||
)
|
||||
).toEqual([
|
||||
[{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }],
|
||||
[{ type: 'text', text: '[Request interrupted by user]' }]
|
||||
])
|
||||
).toEqual([])
|
||||
expect(
|
||||
state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
|
||||
).toBe(false)
|
||||
@@ -521,10 +518,7 @@ describe('Claude structured journal translation', () => {
|
||||
const keyed = new Map(
|
||||
state.items.map((item) => [agentJournalItemKey(item.identity), item.body])
|
||||
)
|
||||
expect(keyed.get('claude:claude-session:user-1')).toMatchObject({
|
||||
kind: 'message',
|
||||
role: 'user'
|
||||
})
|
||||
expect(keyed.has('claude:claude-session:user-1')).toBe(false)
|
||||
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'Bash',
|
||||
@@ -683,7 +677,6 @@ describe('Claude structured journal translation', () => {
|
||||
'message:system:local_command_output',
|
||||
'message:system:command_started',
|
||||
'message:result',
|
||||
'message:user:content:document',
|
||||
'control_request:future_control'
|
||||
])
|
||||
)
|
||||
|
||||
@@ -130,7 +130,15 @@ export function createClaudeJournalTranslator(
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
const body = claudeMessageBody(envelope)
|
||||
// User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
|
||||
const outputEnvelope =
|
||||
envelope.role === 'user'
|
||||
? {
|
||||
...envelope,
|
||||
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
|
||||
}
|
||||
: envelope
|
||||
const body = claudeMessageBody(outputEnvelope)
|
||||
// The final frame of a streamed block lands on the block's identity, not its own uuid.
|
||||
const identity =
|
||||
(body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
|
||||
@@ -140,7 +148,7 @@ export function createClaudeJournalTranslator(
|
||||
deps.sink.appendItem(identity, body)
|
||||
changed = true
|
||||
}
|
||||
for (const tool of claudeToolUses(envelope)) {
|
||||
for (const tool of claudeToolUses(outputEnvelope)) {
|
||||
tools.set(tool.id, tool)
|
||||
deps.sink.appendItem(
|
||||
claudeToolIdentity(envelope.sessionId, tool.id),
|
||||
@@ -162,7 +170,7 @@ export function createClaudeJournalTranslator(
|
||||
tools.delete(result.toolUseId)
|
||||
changed = true
|
||||
}
|
||||
const thinking = claudeThinkingText(envelope)
|
||||
const thinking = claudeThinkingText(outputEnvelope)
|
||||
if (thinking) {
|
||||
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
|
||||
kind: 'status',
|
||||
@@ -170,7 +178,7 @@ export function createClaudeJournalTranslator(
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
|
||||
const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
|
||||
for (const part of unhandledContent) {
|
||||
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
|
||||
providerFallback.append(
|
||||
|
||||
@@ -60,7 +60,10 @@ export class CodexJournalItems {
|
||||
return this.details.get(codexStructuredItemKey(threadId, itemId)) ?? null
|
||||
}
|
||||
|
||||
handle(event: { threadId: string; method: string; params: unknown }): CodexItemTranslation {
|
||||
handle(
|
||||
event: { threadId: string; method: string; params: unknown },
|
||||
source: 'live' | 'history' = 'live'
|
||||
): CodexItemTranslation {
|
||||
const params =
|
||||
typeof event.params === 'object' && event.params !== null
|
||||
? (event.params as Record<string, unknown>)
|
||||
@@ -71,6 +74,10 @@ export class CodexJournalItems {
|
||||
}
|
||||
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
|
||||
const identity = this.identityFor(event.threadId, turnId, item)
|
||||
// Count echoes for stable resume ordinals, but user bubbles come from submissions.
|
||||
if (source === 'live' && item.type === 'userMessage') {
|
||||
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
|
||||
}
|
||||
const translated = codexJournalItem(item)
|
||||
const command = readCodexJournalString(item, 'command')
|
||||
if (command) {
|
||||
|
||||
@@ -652,7 +652,7 @@ describe('codex journal translation', () => {
|
||||
|
||||
translator.handle(TURN_STARTED)
|
||||
translator.handle(
|
||||
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } })
|
||||
notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'one' } })
|
||||
)
|
||||
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
|
||||
translator.handle(
|
||||
@@ -662,7 +662,7 @@ describe('codex journal translation', () => {
|
||||
)
|
||||
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
|
||||
translator.handle(
|
||||
notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } })
|
||||
notification('item/completed', { item: { type: 'agentMessage', id: 'item-2', text: 'two' } })
|
||||
)
|
||||
|
||||
expect(tap.rows.map((row) => row.key)).toEqual([
|
||||
@@ -679,7 +679,7 @@ describe('codex journal translation', () => {
|
||||
translator.handle(
|
||||
notification('item/completed', {
|
||||
turnId: 'turn-9',
|
||||
item: { type: 'userMessage', id: 'item-0', text: 'late' }
|
||||
item: { type: 'agentMessage', id: 'item-0', text: 'late' }
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('codex journal translation', () => {
|
||||
|
||||
translator.handle(TURN_STARTED)
|
||||
translator.handle(
|
||||
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } })
|
||||
notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'hi' } })
|
||||
)
|
||||
|
||||
expect(tap.publishes()).toBe(1)
|
||||
@@ -520,7 +520,7 @@ describe('codex journal translation', () => {
|
||||
expect(timeline).toEqual([])
|
||||
})
|
||||
|
||||
it('projects only user and assistant content for a complete turn with hooks', () => {
|
||||
it('projects assistant content without provider user echoes for a complete turn with hooks', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
|
||||
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
|
||||
@@ -552,7 +552,6 @@ describe('codex journal translation', () => {
|
||||
}))
|
||||
)
|
||||
expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([
|
||||
{ role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
{ role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('codex journal translation', () => {
|
||||
).toBe('idle')
|
||||
})
|
||||
|
||||
it('journals a user turn and the assistant answer under durable codex keys', () => {
|
||||
it('counts a user echo without rendering it and preserves the assistant ordinal', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
|
||||
translator.handle(TURN_STARTED)
|
||||
@@ -317,17 +317,37 @@ describe('codex journal translation', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(tap.rows.map((row) => row.key)).toEqual([
|
||||
'codex:thread-abc:turn-1:0',
|
||||
'codex:thread-abc:turn-1:1'
|
||||
])
|
||||
expect(tap.rows[1]?.body).toEqual({
|
||||
expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
|
||||
expect(tap.rows[0]?.body).toEqual({
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'hello' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses both echo lifecycle frames, including skill and unknown parts', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
translator.handle(TURN_STARTED)
|
||||
const item = {
|
||||
type: 'userMessage',
|
||||
id: 'echo',
|
||||
content: [
|
||||
{ type: 'text', text: 'Expanded instructions' },
|
||||
{ type: 'skill', name: 'example', path: '/tmp/SKILL.md' },
|
||||
{ type: 'future_context', text: 'More context' }
|
||||
]
|
||||
}
|
||||
translator.handle(notification('item/started', { item }))
|
||||
translator.handle(notification('item/completed', { item }))
|
||||
expect(tap.rows).toEqual([])
|
||||
translator.handle(
|
||||
notification('item/completed', {
|
||||
item: { type: 'agentMessage', id: 'answer', text: 'Done' }
|
||||
})
|
||||
)
|
||||
expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
|
||||
})
|
||||
|
||||
it('folds streamed deltas into one snapshot row on the same key the item started under', () => {
|
||||
const { translator, tap, window } = translatorWith()
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function createCodexJournalTranslator(
|
||||
currentTurnIds: activeTurns.byThread,
|
||||
ordinals: items.ordinals,
|
||||
handleItem: (event) => {
|
||||
const translated = items.handle(event)
|
||||
const translated = items.handle(event, 'history')
|
||||
return translated.handled
|
||||
? translated.admission
|
||||
: { accepted: false, reason: 'untranslated' }
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
|
||||
})
|
||||
|
||||
codex.connections[0].handlers.onNotification?.('item/completed', {
|
||||
item: { type: 'userMessage', id: 'message-1', text: 'hello' }
|
||||
item: { type: 'agentMessage', id: 'message-1', text: 'hello' }
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
||||
@@ -207,11 +207,46 @@ describe('submission and dispatch state machine', () => {
|
||||
const items = renderJournalState(state).items
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1'))
|
||||
// The echo updates content in place; the bubble keeps its original slot.
|
||||
// The echo advances the revision; the submitted bubble keeps its original slot.
|
||||
expect(items[0]?.sequence).toBe(1)
|
||||
expect(items[0]?.revision).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['codex:thread-1:turn-1:0', 'claude:session-1:user-1'])(
|
||||
'preserves submitted text and attachments when %s is restored',
|
||||
(providerItemId) => {
|
||||
const body: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: '/example-skill inspect this' },
|
||||
{ type: 'image-ref', path: '/tmp/original.png' }
|
||||
]
|
||||
}
|
||||
const state = fold([
|
||||
{ ...submission, body, payloadFingerprint: sendFingerprint(body) },
|
||||
{
|
||||
kind: 'dispatch',
|
||||
clientMessageId: 'cm_1',
|
||||
state: 'accepted',
|
||||
providerItemId,
|
||||
reason: null,
|
||||
...base(2)
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
itemId: providerItemId,
|
||||
revision: 1,
|
||||
body: userText('# Expanded skill instructions'),
|
||||
...base(3)
|
||||
}
|
||||
])
|
||||
expect(renderJournalState(state).items).toEqual([
|
||||
expect.objectContaining({ itemId: agentJournalSubmissionKey('cm_1'), body, revision: 1 })
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('adopts a provider echo that arrives before dispatch settles', () => {
|
||||
const body = userText('early echo')
|
||||
const state = fold([
|
||||
|
||||
@@ -190,7 +190,17 @@ function upsertItem(
|
||||
// so letting a revision advance it makes the row jump past everything that
|
||||
// landed in between — the provider's own echo of a send revises the submission
|
||||
// row, which relocated the user's bubble below later rows.
|
||||
state.items.set(itemId, { ...next, sequence: existing.sequence, observedAt: existing.observedAt })
|
||||
const submitted =
|
||||
existing.body.kind === 'message' &&
|
||||
existing.body.role === 'user' &&
|
||||
parseAgentJournalItemKey(itemId)?.provider === 'orca'
|
||||
state.items.set(itemId, {
|
||||
...next,
|
||||
// Provider history may normalize text or omit local attachments from the original send.
|
||||
body: submitted ? existing.body : next.body,
|
||||
sequence: existing.sequence,
|
||||
observedAt: existing.observedAt
|
||||
})
|
||||
state.tombstones.delete(itemId)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
|
||||
|
||||
describe('Codex transcript skill context', () => {
|
||||
it.each(['message', 'response_item'])(
|
||||
'preserves prompt text and images beside a skill expansion in %s',
|
||||
(type) => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Inspect this image' },
|
||||
{ type: 'text', text: '<skill>\nInstructions\n</skill>' },
|
||||
{ type: 'image', url: 'https://example.test/image.png' }
|
||||
]
|
||||
}
|
||||
const record = type === 'message' ? message : { type, payload: message }
|
||||
expect(decodeCodexTranscriptLine(JSON.stringify(record), 'mixed')?.blocks).toEqual([
|
||||
{ type: 'text', text: 'Inspect this image' },
|
||||
{ type: 'image-ref', url: 'https://example.test/image.png' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves an authoritative user event containing a literal skill wrapper', () => {
|
||||
const text = '<skill>Explain this XML</skill>'
|
||||
expect(
|
||||
decodeCodexTranscriptLine(
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } }),
|
||||
'submitted'
|
||||
)?.blocks
|
||||
).toEqual([{ type: 'text', text }])
|
||||
})
|
||||
|
||||
it.each(['<skill>', ' \n<SKILL>'])(
|
||||
'drops expanded skill response items beginning with %j',
|
||||
(prefix) => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `${prefix}\n<name>example</name>\nInstructions\n</skill>` }]
|
||||
}
|
||||
for (const record of [message, { type: 'response_item', payload: message }]) {
|
||||
expect(decodeCodexTranscriptLine(JSON.stringify(record), 'context')).toBeNull()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['$example', 'Explain <skill> tags', '<skillset>user XML</skillset>'])(
|
||||
'preserves the actual user prompt %j',
|
||||
(text) => {
|
||||
expect(
|
||||
decodeCodexTranscriptLine(
|
||||
JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }]
|
||||
}
|
||||
}),
|
||||
'user'
|
||||
)?.blocks
|
||||
).toEqual([{ type: 'text', text }])
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves assistant explanations containing the skill wrapper', () => {
|
||||
expect(
|
||||
decodeCodexTranscriptLine(
|
||||
JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '<skill>example</skill>' }]
|
||||
}
|
||||
}),
|
||||
'assistant'
|
||||
)?.role
|
||||
).toBe('assistant')
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,9 @@ function codexUnwrappedResponseItem(
|
||||
return codexResponseItem(record, id, timestamp)
|
||||
}
|
||||
const role = record.role === 'assistant' ? 'assistant' : record.role === 'user' ? 'user' : null
|
||||
const blocks = codexTurnItemBlocks(record.content)
|
||||
const decodedBlocks = codexTurnItemBlocks(record.content)
|
||||
const blocks =
|
||||
role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
|
||||
return role && blocks.length > 0 ? { id, role, blocks, timestamp, source: 'transcript' } : null
|
||||
}
|
||||
|
||||
@@ -63,7 +65,9 @@ function codexResponseItem(
|
||||
if (!role) {
|
||||
return null
|
||||
}
|
||||
const blocks = claudeContentBlocks(payload.content)
|
||||
const decodedBlocks = claudeContentBlocks(payload.content)
|
||||
const blocks =
|
||||
role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
|
||||
if (blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -108,6 +112,11 @@ function codexResponseItem(
|
||||
return null
|
||||
}
|
||||
|
||||
// Explicit skill expansions are model context, not the user's recorded prompt.
|
||||
function isSkillContext(block: NativeChatBlock): boolean {
|
||||
return block.type === 'text' && block.text.trimStart().slice(0, 7).toLowerCase() === '<skill>'
|
||||
}
|
||||
|
||||
function codexEventMessage(
|
||||
payload: Record<string, unknown>,
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user