Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
559 changes: 559 additions & 0 deletions docs/plans/2026-09-18-pty-notification-display-role.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions shared/fresh-agent-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,44 @@ export function freshAgentTurnText(turn: Pick<FreshAgentTurn, 'summary' | 'items
return textItems.length > 0 ? text : turn.summary
}

/**
* Leading display text of a turn: the FIRST text item's text when one exists,
* else the summary. The opencode-pty plugin injects its notification blocks as
* the leading content of a user-role message, in both shapes.
*/
function leadingFreshAgentTurnText(turn: Pick<FreshAgentTurn, 'summary' | 'items'>): string {
const firstText = turn.items.find(
(item): item is Extract<FreshAgentTurn['items'][number], { kind: 'text' }> => item.kind === 'text',
)
return (firstText?.text ?? turn.summary ?? '').trim()
}

const PTY_NOTIFICATION_TURN_PREFIXES = ['<pty_exited>', '<pty_waited>', '<pty_wait_timeout>'] as const

function isPtyNotificationTurn(turn: Pick<FreshAgentTurn, 'role' | 'summary' | 'items'>): boolean {
if (turn.role !== 'user') return false
const leading = leadingFreshAgentTurnText(turn)
return PTY_NOTIFICATION_TURN_PREFIXES.some((prefix) => leading.startsWith(prefix))
}

/**
* Display-only: opencode-pty plugin notification turns (machine-injected
* user-role messages whose leading text is a `<pty_exited>` / `<pty_waited>` /
* `<pty_wait_timeout>` block) present as agent text in the transcript. The
* wire snapshot and store turns are never mutated — non-matching turns keep
* their identity and the input array is returned unchanged when nothing
* matches, so memoized consumers stay referentially stable.
*/
export function reclassifyPtyNotificationTurns(turns: FreshAgentTurn[]): FreshAgentTurn[] {
let changed = false
const mapped = turns.map((turn) => {
if (!isPtyNotificationTurn(turn)) return turn
changed = true
return { ...turn, role: 'assistant' as const }
})
return changed ? mapped : turns
}

function normalizeTurnRole(role: unknown): string | undefined {
return typeof role === 'string' ? role.trim().toLowerCase() : undefined
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/fresh-agent/FreshAgentTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { FreshAgentTranscriptMinimap } from './FreshAgentTranscriptMinimap'
import { deriveGlomTarget, measureTranscriptUserTurns, type TranscriptMeasurement } from './shared/transcript-measurement'
import { registerFreshAgentTurnItems } from '@/lib/pane-action-registry'
import { buildLongPressHandlers, useCoarsePointer } from '@/lib/pointer'
import { getFreshAgentDisplayTurnKey, turnSummaryIsAuthored } from '@shared/fresh-agent-turns'
import { getFreshAgentDisplayTurnKey, reclassifyPtyNotificationTurns, turnSummaryIsAuthored } from '@shared/fresh-agent-turns'

function getTurnLabel(turn: FreshAgentTurn, agentLabel?: string): string {
switch (turn.role) {
Expand Down Expand Up @@ -1102,7 +1102,7 @@ export const FreshAgentTranscript = forwardRef<FreshAgentTranscriptHandle, Fresh
const historicalSteps = historicalMarkers.filter((t) => t.role === 'user').length
const resolvedShowTimecodes = showTimecodes ?? showModel
const displayTurns = useMemo(() => (
coalesceSyntheticToolResultTurns(turns)
coalesceSyntheticToolResultTurns(reclassifyPtyNotificationTurns(turns))
), [turns])
const { layouts: turnLayouts, lineEndIndex, tail, tailCaption } = useMemo(
() => buildTranscriptLayout(displayTurns),
Expand Down
116 changes: 116 additions & 0 deletions test/e2e-browser/specs/fresh-agent-pty-notification-display.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { test, expect } from '../helpers/fixtures.js'

function tallBody(tag: string): string {
return `${tag}.\n\n` + Array.from(
{ length: 60 },
(_, i) => `${tag} line ${i + 1}: the quick brown fox jumps over the lazy dog.`,
).join('\n\n')
}

const PTY_EXITED_BLOCK = [
'<pty_exited>',
'ID: pty_d75e6fa9',
'Description: Live backup sync run, script via stdin',
'Exit Code: 0',
'TimeoutSeconds: 3600',
'Timed Out: no',
'Output Lines: 5',
'Last Line: SYNC_EXIT=0',
'</pty_exited>',
'',
'Use pty_read to check the full output.',
].join('\n')

/** Convert the active terminal leaf into a freshopencode pane whose routed
* thread snapshot carries the given turns. Same shape as transcript-minimap.spec.ts's
* seedMinimapPane (which cites fresh-agent.spec.ts's installFreshclaudeStripPane):
* network effects suppressed BEFORE the conversion so the pane never
* WS-connects; the REST snapshot is the only fetch. No provider binary
* involved, so the spec is cloud-legal. */
async function seedOpencodePane(page: any, sessionId: string, turns: unknown[]) {
await page.route(`**/api/fresh-agent/threads/freshopencode/opencode/${sessionId}*`, async (route: any) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sessionType: 'freshopencode',
provider: 'opencode',
threadId: sessionId,
sessionId,
revision: 1,
latestTurnId: (turns[turns.length - 1] as { id?: string } | undefined)?.id ?? null,
status: 'idle',
summary: '',
// Capability values mirror the Rust opencode snapshot builder
// (crates/freshell-freshagent/src/lib.rs, build_opencode_snapshot_json).
capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: true },
settings: { model: 'default', permissionMode: 'default', plugins: [] },
tokenUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2, costUsd: 0 },
pendingApprovals: [],
pendingQuestions: [],
turns,
extensions: {},
}),
})
})
await page.evaluate((currentSessionId) => {
const harness = window.__FRESHELL_TEST_HARNESS__
const state = harness?.getState()
const tabId = state?.tabs?.activeTabId as string | undefined
const paneId = tabId ? state?.panes?.activePane?.[tabId] : null
if (!tabId || !paneId) return
harness.setFreshAgentNetworkEffectsSuppressed(paneId, true)
harness.dispatch({
type: 'panes/updatePaneContent',
payload: {
tabId,
paneId,
content: {
kind: 'fresh-agent',
sessionType: 'freshopencode',
provider: 'opencode',
createRequestId: `req-pty-notify-${currentSessionId}`,
sessionId: currentSessionId,
sessionRef: { provider: 'opencode', sessionId: currentSessionId },
resumeSessionId: currentSessionId,
status: 'idle',
settingsDismissed: true,
},
},
})
}, sessionId)
}

test.describe('fresh-agent PTY notification display role', () => {
test('renders opencode-pty exit notifications as agent text with no minimap tick', async ({ freshellPage: _freshellPage, page, terminal }) => {
await terminal.waitForTerminal()
const sessionId = '63333000-0000-4333-8333-0000000bb201'
await seedOpencodePane(page, sessionId, [
{ id: 'turn-pty-u1', turnId: 'turn-pty-u1', role: 'user', summary: 'Run the backup sync now', items: [{ id: 'item-pty-u1', kind: 'text', text: 'Run the backup sync now' }] },
{ id: 'turn-pty-a1', turnId: 'turn-pty-a1', role: 'assistant', summary: 'Starting it in a background session', items: [{ id: 'item-pty-a1', kind: 'text', text: tallBody('Starting') }] },
{ id: 'turn-pty-n1', turnId: 'turn-pty-n1', role: 'user', summary: PTY_EXITED_BLOCK, items: [{ id: 'item-pty-n1', kind: 'text', text: PTY_EXITED_BLOCK }] },
{ id: 'turn-pty-a2', turnId: 'turn-pty-a2', role: 'assistant', summary: 'Sync finished cleanly', items: [{ id: 'item-pty-a2', kind: 'text', text: tallBody('Finished') }] },
])

const freshPane = page.locator('[data-context="fresh-agent"]')
await expect(freshPane).toBeVisible({ timeout: 10_000 })
const scroller = freshPane.locator('[data-context="fresh-agent-transcript"]')
await expect(scroller).toBeVisible({ timeout: 10_000 })

// The notification renders as AGENT text: assistant article, no 'You' inside it.
const ptyArticle = freshPane.locator('article[data-turn-role="assistant"]', { hasText: 'SYNC_EXIT=0' })
await expect(ptyArticle).toBeVisible()
await expect(ptyArticle.getByText('You')).toHaveCount(0)
// The real human prompt is still user text.
await expect(freshPane.locator('article[data-turn-role="user"]', { hasText: 'Run the backup sync now' })).toBeVisible()
// No user article carries the PTY block.
await expect(freshPane.locator('article[data-turn-role="user"]', { hasText: 'SYNC_EXIT=0' })).toHaveCount(0)

// Minimap: exactly one tick — the real human prompt — and its label is
// that prompt, never the PTY block.
await expect(freshPane.getByTestId('transcript-minimap-viewport')).toBeVisible()
const ticks = freshPane.getByRole('button', { name: /Jump to prompt:/ })
await expect(ticks).toHaveCount(1)
await expect(freshPane.getByRole('button', { name: 'Jump to prompt: Run the backup sync now', exact: true })).toBeVisible()
})
})
109 changes: 109 additions & 0 deletions test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3221,3 +3221,112 @@ describe('FreshAgentTranscript task delegation + retry folding', () => {
})
})
})

describe('PTY notification display role (opencode-pty plugin turns)', () => {
afterEach(() => cleanup())

const PTY_BLOCK = '<pty_exited>\nID: pty_d75e6fa9\nExit Code: 0\nTimed Out: no\nOutput Lines: 5\nLast Line: SYNC_EXIT=0\n</pty_exited>\n\nUse pty_read to check the full output.'

it('renders a <pty_exited> user-role turn as agent text, never You', () => {
const { container } = render(
<FreshAgentTranscript
turns={[
{ id: 'u1', turnId: 'u1', role: 'user', summary: 'please run the gate', items: [{ id: 'u1-i', kind: 'text', text: 'please run the gate' }] },
{ id: 'pty1', turnId: 'pty1', role: 'user', summary: PTY_BLOCK, items: [{ id: 'pty1-i', kind: 'text', text: PTY_BLOCK }] },
{ id: 'a1', turnId: 'a1', role: 'assistant', summary: 'gate done', items: [{ id: 'a1-i', kind: 'text', text: 'gate done' }] },
]}
/>,
)

// The real human prompt is still user text: exactly one user article.
expect(screen.getByText('You')).toBeInTheDocument()
expect(container.querySelectorAll('article[data-turn-role="user"]')).toHaveLength(1)

// The PTY notification renders as agent text: assistant article, no You
// header inside it, and an Assistant header (preceded by a user turn).
// exact:false because the block renders as markdown paragraphs whose text
// is 'Last Line: SYNC_EXIT=0', not the bare token.
const ptyArticle = screen.getByText('SYNC_EXIT=0', { exact: false }).closest('article')
expect(ptyArticle).not.toBeNull()
expect(ptyArticle?.getAttribute('data-turn-role')).toBe('assistant')
expect(within(ptyArticle as HTMLElement).queryByText('You')).not.toBeInTheDocument()
expect(within(ptyArticle as HTMLElement).getByText('Assistant')).toBeInTheDocument()
})

it('renders <pty_exited> turn text through the markdown path, not as literal user text', () => {
const text = '<pty_exited>\n**sync finished**\n</pty_exited>'
const { container } = render(
<FreshAgentTranscript
turns={[{ id: 'pty1', turnId: 'pty1', role: 'user', summary: text, items: [{ id: 'pty1-i', kind: 'text', text }] }]}
/>,
)

// markdown={!isUser}: the reclassified turn's **…** renders as <strong>.
expect(container.querySelector('article[data-turn-role="assistant"] strong')).not.toBeNull()
})

it('folds a <pty_exited> turn as a continuation of a preceding assistant turn', () => {
const { container } = render(
<FreshAgentTranscript
agentLabel="Freshopencode"
turns={[
{ id: 'a1', turnId: 'a1', role: 'assistant', summary: 'first answer', items: [{ id: 'a1-i', kind: 'text', text: 'first answer' }] },
{ id: 'pty1', turnId: 'pty1', role: 'user', summary: PTY_BLOCK, items: [{ id: 'pty1-i', kind: 'text', text: PTY_BLOCK }] },
]}
/>,
)

// One speaker header for the whole assistant run; no You anywhere; both
// articles carry the assistant role.
expect(screen.getAllByText('Freshopencode')).toHaveLength(1)
expect(screen.queryByText('You')).not.toBeInTheDocument()
expect(container.querySelectorAll('article[data-turn-role="assistant"]')).toHaveLength(2)
})

it('gives a <pty_exited> turn the same toolbar affordances as any assistant turn (no Rewind button)', () => {
// Agent-text parity pin: the hover toolbar's 'Rewind code to here' button
// renders only for user-role turns. After reclassification the PTY turn
// gets the identical (absent) toolbar affordance as any assistant turn.
// The context menu / touch action sheet keep their disabled Undo/Rewind
// entries for the reclassified turn — unchanged by design, exactly as for
// every other assistant turn; that surface is out of scope.
const onRewind = vi.fn()
render(
<FreshAgentTranscript
canFork={false}
onRewindToTurn={onRewind}
turns={[
{ id: 'u1', turnId: 'u1', role: 'user', summary: 'run the gate', items: [{ id: 'u1-i', kind: 'text', text: 'run the gate' }] },
{ id: 'pty1', turnId: 'pty1', role: 'user', summary: PTY_BLOCK, items: [{ id: 'pty1-i', kind: 'text', text: PTY_BLOCK }] },
{ id: 'a1', turnId: 'a1', role: 'assistant', summary: 'done', items: [{ id: 'a1-i', kind: 'text', text: 'done' }] },
]}
/>,
)

const rewindButtons = screen.getAllByRole('button', { name: 'Rewind code to here' })
expect(rewindButtons).toHaveLength(1)
fireEvent.click(rewindButtons[0])
expect(onRewind).toHaveBeenCalledWith(expect.objectContaining({ id: 'u1', role: 'user' }))
})

it('counts a rolled-back <pty_exited> marker row as a user step (raw rule: the classifier never feeds the stepper)', () => {
// Constraint guard, not new behavior: rolledBackTurns is a raw prop that
// never passes through the displayTurns memo. A <pty_exited> marker row
// MUST still count toward the step label pinned to the server's
// rollback.undoneDepth (see the rolled-back section tests at :2891).
render(
<FreshAgentTranscript
turns={[{ id: 'u1', turnId: 'u1', role: 'user', summary: 'live prompt', items: [{ id: 'u1-i', kind: 'text', text: 'live prompt' }] }]}
rolledBackTurns={[
{ id: 'u2', turnId: 'u2', role: 'user', summary: 'second prompt', items: [{ id: 'u2-i', kind: 'text', text: 'second prompt' }], rolledBack: true, restorable: true },
{ id: 'a2', turnId: 'a2', role: 'assistant', summary: 'second answer', items: [{ id: 'a2-i', kind: 'text', text: 'second answer' }], rolledBack: true, restorable: true },
{ id: 'pty1', turnId: 'pty1', role: 'user', summary: PTY_BLOCK, items: [{ id: 'pty1-i', kind: 'text', text: PTY_BLOCK }], rolledBack: true, restorable: true },
]}
/>,
)

// Two USER-role marker rows (real prompt + PTY notification) => (2), not (1):
// routing marker rows through the classifier would break the sum-to-undoneDepth pin.
expect(screen.getByText('Rolled back (2) — gone from the conversation; redo to restore.')).toBeInTheDocument()
})
})
80 changes: 79 additions & 1 deletion test/unit/shared/fresh-agent-turns.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'

import { FreshAgentTurnSchema } from '../../../shared/fresh-agent-contract.js'
import { FreshAgentTurnSchema, type FreshAgentTurn } from '../../../shared/fresh-agent-contract.js'
import {
freshAgentSnapshotHasUserTurn,
freshAgentTurnText,
getFreshAgentDisplayTurnKey,
reclassifyPtyNotificationTurns,
turnSummaryIsAuthored,
} from '../../../shared/fresh-agent-turns.js'

Expand Down Expand Up @@ -103,4 +104,81 @@ describe('fresh-agent display turn helpers', () => {
providerTurnId: 'legacy-id',
})).toThrow()
})

describe('reclassifyPtyNotificationTurns', () => {
const ptyTurn = (text: string, summary = text): FreshAgentTurn => ({
id: 'pty-1',
turnId: 'pty-1',
role: 'user',
summary,
items: [{ id: 'pty-1-i0', kind: 'text', text }],
})

it('reclassifies <pty_exited>, <pty_waited>, and <pty_wait_timeout> user turns to assistant', () => {
for (const tag of ['<pty_exited>', '<pty_waited>', '<pty_wait_timeout>']) {
const turn = ptyTurn(`${tag}\nID: pty_x\nExit Code: 0`)
const [mapped] = reclassifyPtyNotificationTurns([turn])
expect(mapped?.role).toBe('assistant')
expect(mapped?.id).toBe('pty-1')
expect(mapped?.items).toEqual(turn.items)
}
})

it('matches leading-tag text after trimming, and falls back to summary when no text item exists', () => {
const [withWhitespace] = reclassifyPtyNotificationTurns([ptyTurn(' <pty_exited>\nLast Line: SYNC_EXIT=0')])
expect(withWhitespace?.role).toBe('assistant')

const summaryOnly: FreshAgentTurn = {
id: 'pty-2',
turnId: 'pty-2',
role: 'user',
summary: '<pty_exited>\nno items on this degraded snapshot',
items: [],
}
const [fromSummary] = reclassifyPtyNotificationTurns([summaryOnly])
expect(fromSummary?.role).toBe('assistant')
})

it('does not match when the tag appears mid-message (leading-anchored only)', () => {
const turn = ptyTurn('Result of the run:\n<pty_exited>\nID: pty_x')
const [mapped] = reclassifyPtyNotificationTurns([turn])
expect(mapped?.role).toBe('user')
})

it('leaves non-matching user turns untouched and never touches non-user roles', () => {
const plainUser: FreshAgentTurn = { id: 'u1', turnId: 'u1', role: 'user', summary: 'real prompt', items: [{ id: 'u1-i0', kind: 'text', text: 'real prompt' }] }
const taggedAssistant: FreshAgentTurn = { id: 'a1', turnId: 'a1', role: 'assistant', summary: '<pty_exited>', items: [{ id: 'a1-i0', kind: 'text', text: '<pty_exited>' }] }
// A user turn with NO text item (verified item shape from this file's
// existing `freshAgentTurnText` test) whose summary does not lead
// with a tag: leading-text extraction falls to the summary, no match.
const noTextItem: FreshAgentTurn = { id: 's1', turnId: 's1', role: 'user', summary: 'tool output', items: [{ id: 's1-i0', kind: 'thinking', text: 'internal' }] }

const turns = [plainUser, taggedAssistant, noTextItem]
const mapped = reclassifyPtyNotificationTurns(turns)

expect(mapped).toBe(turns) // same array reference: nothing matched
expect(mapped[0]).toBe(plainUser)
expect(mapped[1]?.role).toBe('assistant')
expect(mapped[2]).toBe(noTextItem)
})

it('returns a new array with new objects only for matches, preserving order and identity of the rest', () => {
const plain: FreshAgentTurn = { id: 'u1', turnId: 'u1', role: 'user', summary: 'hi', items: [{ id: 'u1-i0', kind: 'text', text: 'hi' }] }
const pty = ptyTurn('<pty_exited>\nID: pty_x')
const turns = [plain, pty]
const mapped = reclassifyPtyNotificationTurns(turns)

expect(mapped).not.toBe(turns)
expect(mapped).toHaveLength(2)
expect(mapped[0]).toBe(plain)
expect(mapped[1]).not.toBe(pty)
expect({ ...mapped[1], role: 'user' }).toEqual(pty)
})

it('does not mutate its input (wire/store turns stay raw)', () => {
const pty = ptyTurn('<pty_exited>\nID: pty_x')
reclassifyPtyNotificationTurns([pty])
expect(pty.role).toBe('user')
})
})
})
Loading