diff --git a/ui-tui/src/__tests__/useSubmission.test.ts b/ui-tui/src/__tests__/useSubmission.test.ts new file mode 100644 index 000000000000..6d7f1fdf25d2 --- /dev/null +++ b/ui-tui/src/__tests__/useSubmission.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import type { ComposerToken } from '../app/interfaces.js' +import { prepareSubmission, shouldInterpolateSubmission } from '../app/useSubmission.js' + +describe('prepareSubmission', () => { + it('keeps the collapsed paste for display and expands the model payload', () => { + const label = '[[ first.. [3 lines] .. last ]]' + const tokens: ComposerToken[] = [{ kind: 'paste', label, text: 'first\nmiddle\nlast' }] + + expect(prepareSubmission(`review this: ${label}`, tokens)).toEqual({ + display: `review this: ${label}`, + text: 'review this: first\nmiddle\nlast' + }) + }) + + it('does not execute interpolation syntax hidden inside pasted content', () => { + const label = '[[ copied log [1 lines] ]]' + const tokens: ComposerToken[] = [{ kind: 'paste', label, text: 'untrusted {!touch /tmp/pwned}' }] + const submission = prepareSubmission(label, tokens) + + expect(shouldInterpolateSubmission(submission.display)).toBe(false) + expect(submission.text).toContain('{!touch /tmp/pwned}') + }) +}) + +describe('visible interpolation combined with a collapsed paste', () => { + it('routes to interpolation when {!...} is visible in the composer alongside a paste token', () => { + const label = '[[ log [2 lines] ]]' + + expect(shouldInterpolateSubmission(`show {!date} for ${label}`)).toBe(true) + }) + + // The interpolation branch of dispatchSubmission submits + // send(prepareSubmission(text, tokens).text, true, text, identity) + // where `text` is interpolate()'s output: the visible {!...} already resolved, + // with the collapsed paste label still intact. This asserts both halves of + // that composition so the transcript shows resolved interpolation + the + // compact paste, while the model receives resolved interpolation + the full + // expanded paste. + it('display keeps resolved interpolation and the compact paste; payload expands the paste', () => { + const label = '[[ log [2 lines] ]]' + const tokens: ComposerToken[] = [{ kind: 'paste', label, text: 'line one\nline two' }] + + // interpolate() has resolved the visible {!date} -> "Tue" and left the paste label alone. + const interpolated = `Tue for ${label}` + const submission = prepareSubmission(interpolated, tokens) + + expect(submission.display).toBe(`Tue for ${label}`) + expect(submission.display).not.toContain('{!') + expect(submission.text).toBe('Tue for line one\nline two') + }) + + // Regression guard for the display bug teknium1 flagged: the transcript must + // not fall back to the PRE-interpolation composer text, which still carries + // the literal {!...}. + it('does not show the raw {!...} syntax as the transcript display', () => { + const label = '[[ log [2 lines] ]]' + const tokens: ComposerToken[] = [{ kind: 'paste', label, text: 'line one\nline two' }] + + const preInterpolation = `show {!date} for ${label}` + expect(prepareSubmission(preInterpolation, tokens).display).toContain('{!') + }) +}) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 3862a8099b4c..a0c18f3b6f29 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -35,6 +35,13 @@ export const queueItemFromSlash = (displayCommand: string, expandedCommand: stri return queueItem(slashArgument(expandedCommand), display) } +export const prepareSubmission = (display: string, tokens: ComposerToken[]) => ({ + display, + text: expandTokens(tokens)(display) +}) + +export const shouldInterpolateSubmission = (display: string) => hasInterpolation(display) + export function useSubmission(opts: UseSubmissionOptions) { const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } = opts @@ -72,10 +79,15 @@ export function useSubmission(opts: UseSubmissionOptions) { }, [composerState.input, composerState.inputBuf]) const send = useCallback( - (text: string, showUserMessage = true, displayText?: string) => { + ( + text: string, + showUserMessage = true, + displayText?: string, + expandOverride?: (value: string) => string + ) => { // Read tokens off the ref, not render state: a paste immediately followed // by Enter submits before React has re-rendered with the new token. - const expand = expandTokens(composerRefs.tokensRef.current) + const expand = expandOverride ?? expandTokens(composerRefs.tokensRef.current) submitPrompt( text, @@ -228,8 +240,10 @@ export function useSubmission(opts: UseSubmissionOptions) { // nothing — a detached image can't be re-attached by recalling the text. // Idempotent on token-free text, so re-submitting a recalled entry is // stable. - const toHistory = expandTokens(composerRefs.tokensRef.current)(full) - const queuePayload = expandPasteTokens(composerRefs.tokensRef.current)(full) + const submissionTokens = [...composerRefs.tokensRef.current] + const submission = prepareSubmission(full, submissionTokens) + const toHistory = submission.text + const queuePayload = expandPasteTokens(submissionTokens)(full) if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) @@ -299,13 +313,15 @@ export function useSubmission(opts: UseSubmissionOptions) { return handleBusyInput(queueItem(full)) } - if (hasInterpolation(full)) { + if (shouldInterpolateSubmission(full)) { patchUiState({ busy: true }) - return interpolate(full, send) + return interpolate(full, text => + send(prepareSubmission(text, submissionTokens).text, true, text, value => value) + ) } - send(full) + send(submission.text, true, submission.display, value => value) }, [ appendMessage,