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
64 changes: 64 additions & 0 deletions ui-tui/src/__tests__/useSubmission.test.ts
Original file line number Diff line number Diff line change
@@ -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('{!')
})
})
30 changes: 23 additions & 7 deletions ui-tui/src/app/useSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down
Loading