Skip to content
Open
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
21 changes: 20 additions & 1 deletion console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useConversationsCtxOptional } from '@/lib/conversations-context'
import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions'
import { formatStopReason } from '@/lib/format-stop-reason'
import { newMessageId } from '@/lib/session-id'
import { isEmptyFcallInput } from '@/lib/sessions/entry-mapper'
import { cn } from '@/lib/utils'
import { fetchDefaultWorkingDir, validateWorkspaceDir } from '@/lib/working-dir'
import {
Expand Down Expand Up @@ -552,7 +553,7 @@ export function ChatView({
if (event.kind === 'fcall-start') {
const existing = event.functionCallId
? messagesRef.current.find(
(message) =>
(message): message is FunctionCallMessage =>
message.role === 'function-call' &&
message.functionCallId === event.functionCallId,
)
Expand All @@ -564,6 +565,13 @@ export function ChatView({
functionCallId: event.functionCallId,
sessionId: event.sessionId,
filesystemAccess: event.filesystemAccess,
// The approval record's arguments_excerpt is the only source of
// args when the transcript row degraded to empty/streaming (the
// wrapper payload was dropped or still forming at snapshot time).
...(isEmptyFcallInput(existing.input) &&
!isEmptyFcallInput(event.input)
? { input: event.input }
: {}),
})
return
}
Expand Down Expand Up @@ -1065,12 +1073,23 @@ export function ChatView({
m.functionCallId === event.functionCallId,
)?.id
if (existing) {
const existingRow = messagesRef.current.find(
(m): m is FunctionCallMessage =>
m.role === 'function-call' && m.id === existing,
)
onPatchMessage(conversationId, existing, {
pendingApproval: event.pendingApproval,
running: !event.pendingApproval,
functionCallId: event.functionCallId,
sessionId: event.sessionId,
filesystemAccess: event.filesystemAccess,
// Backfill args from the approval record's excerpt when
// the row's transcript-derived input degraded to empty.
...(existingRow &&
isEmptyFcallInput(existingRow.input) &&
!isEmptyFcallInput(event.input)
? { input: event.input }
: {}),
})
fcallId = existing
break
Expand Down
24 changes: 20 additions & 4 deletions console/web/src/components/chat/coder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import { InfoView } from './InfoView'
import { ListFolderView } from './ListFolderView'
import { MovePreview, MoveView } from './MoveView'
import {
createFileRequestSchema,
deleteFileRequestSchema,
isCoderFunction,
isCoderMutateFunction,
moveFileRequestSchema,
safeParseRequest,
unwrapEnvelope,
updateFileRequestSchema,
} from './parsers'
import { ReadFileView } from './ReadFileView'
import { SearchView } from './SearchView'
Expand Down Expand Up @@ -76,15 +81,26 @@ function tryRenderPreview(
): React.ReactNode | null {
if (!isCoderMutateFunction(message.functionId)) return null
const input = unwrapEnvelope(message.input)
// Parse-check BEFORE building an element: the preview components render
// null for an unparseable request, but a non-null element here makes
// FunctionCallCard suppress its raw request pane (the null contract).
switch (message.functionId) {
case 'coder::create-file':
return <CreateFilePreview input={input} />
return safeParseRequest(createFileRequestSchema, input) ? (
<CreateFilePreview input={input} />
) : null
case 'coder::update-file':
return <UpdateFilePreview input={input} />
return safeParseRequest(updateFileRequestSchema, input) ? (
<UpdateFilePreview input={input} />
) : null
case 'coder::delete-file':
return <DeleteFilePreview input={input} />
return safeParseRequest(deleteFileRequestSchema, input) ? (
<DeleteFilePreview input={input} />
) : null
case 'coder::move':
return <MovePreview input={input} />
return safeParseRequest(moveFileRequestSchema, input) ? (
<MovePreview input={input} />
) : null
default:
return null
}
Expand Down
15 changes: 13 additions & 2 deletions console/web/src/components/chat/harness/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView'
import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers'
import type { FunctionCallMessage } from '@/types/chat'
import { isHarnessFunction, unwrapEnvelope } from './parsers'
import {
isHarnessFunction,
safeParseRequest,
spawnRequestSchema,
unwrapEnvelope,
} from './parsers'
import { SpawnPreview, SpawnView } from './SpawnView'
import { SubmitResultView } from './SubmitResultView'

Expand Down Expand Up @@ -65,7 +70,13 @@ function tryRenderPreview(
message: FunctionCallMessage,
): React.ReactNode | null {
if (message.functionId !== 'harness::spawn') return null
return <SpawnPreview input={unwrapEnvelope(message.input)} />
const input = unwrapEnvelope(message.input)
// Parse-check BEFORE building an element: SpawnPreview renders null for
// an unparseable request, but a non-null element here makes
// FunctionCallCard suppress its raw request pane (the null contract).
return safeParseRequest(spawnRequestSchema, input) ? (
<SpawnPreview input={input} />
) : null
}

export const HarnessToolView = {
Expand Down
18 changes: 15 additions & 3 deletions console/web/src/components/chat/sandbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { FsSedView } from './FsSedView'
import { FsStatView } from './FsStatView'
import { FsWriteView } from './FsWriteView'
import { ListView } from './ListView'
import { parseSandboxErrorDisplay, unwrapEnvelope } from './parsers'
import {
execRequestSchema,
parseSandboxErrorDisplay,
runRequestSchema,
unwrapEnvelope,
} from './parsers'
import { RunPreview, RunView } from './RunView'
import { StopView } from './StopView'

Expand Down Expand Up @@ -118,11 +123,18 @@ function tryRenderPreview(
): React.ReactNode | null {
if (!isSandboxFunction(message.functionId)) return null
const input = unwrapEnvelope(message.input)
// Parse-check BEFORE building an element: the preview components render
// null for an unparseable request, but a non-null element here makes
// FunctionCallCard suppress its raw request pane (the null contract).
switch (message.functionId) {
case 'sandbox::exec':
return <ExecPreview input={input} />
return execRequestSchema.safeParse(input).success ? (
<ExecPreview input={input} />
) : null
case 'sandbox::run':
return <RunPreview input={input} />
return runRequestSchema.safeParse(input).success ? (
<RunPreview input={input} />
) : null
default:
return null
}
Expand Down
32 changes: 24 additions & 8 deletions console/web/src/components/chat/scrapling/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { FetchPreview, FetchView } from './FetchView'
import { MarkdownView } from './MarkdownView'
import { ExtractView, FindSimilarView, QueryView } from './ParseViews'
import {
crawlRequestSchema,
ELEMENT_SEARCH_FUNCTION_IDS,
FETCH_FUNCTION_IDS,
fetchRequestSchema,
isScraplingFunction,
SESSION_GATED_FUNCTION_IDS,
safeParseRequest,
screenshotRequestSchema,
sessionOpenRequestSchema,
unwrapEnvelope,
} from './parsers'
import { ScreenshotPreview, ScreenshotView } from './ScreenshotView'
Expand Down Expand Up @@ -130,21 +135,32 @@ function tryRenderPreview(
): React.ReactNode | null {
if (!isScraplingFunction(message.functionId)) return null
const input = unwrapEnvelope(message.input)
// Parse-check BEFORE building an element: the preview components render
// null for an unparseable request, but a non-null element here makes
// FunctionCallCard suppress its raw request pane (the null contract).
// SessionFetchPreview is exempt — it renders a header for any input.
if (FETCH_FUNCTION_IDS.has(message.functionId)) {
return <FetchPreview functionId={message.functionId} input={input} />
return safeParseRequest(fetchRequestSchema, input) ? (
<FetchPreview functionId={message.functionId} input={input} />
) : null
}
if (message.functionId === 'scrapling::screenshot') {
return <ScreenshotPreview input={input} />
return safeParseRequest(screenshotRequestSchema, input) ? (
<ScreenshotPreview input={input} />
) : null
}
if (SESSION_GATED_FUNCTION_IDS.has(message.functionId)) {
return message.functionId === 'scrapling::session-open' ? (
<SessionOpenPreview input={input} />
) : (
<SessionFetchPreview input={input} />
)
if (message.functionId === 'scrapling::session-open') {
return safeParseRequest(sessionOpenRequestSchema, input) ? (
<SessionOpenPreview input={input} />
) : null
}
return <SessionFetchPreview input={input} />
}
if (message.functionId === 'scrapling::crawl') {
return <CrawlPreview input={input} />
return safeParseRequest(crawlRequestSchema, input) ? (
<CrawlPreview input={input} />
) : null
}
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { FunctionCallMessage } from '@/types/chat'
import { ShellToolView } from '../index'

function pendingCall(functionId: string, input: unknown): FunctionCallMessage {
return {
id: 'm1',
role: 'function-call',
functionId,
input,
pendingApproval: true,
createdAt: 0,
}
}

/* FunctionCallCard falls back to its raw request pane only when
tryRenderPreview returns null. An element that renders nothing would
leave a held call with no visible arguments at all (MOT-4101), so the
parse decision must happen here, not inside the preview component. */
describe('ShellToolView.tryRenderPreview null contract', () => {
it('returns an element only when the request parses', () => {
expect(
ShellToolView.tryRenderPreview(
pendingCall('shell::exec', { command: 'ls -la' }),
),
).not.toBeNull()
expect(
ShellToolView.tryRenderPreview(
pendingCall('shell::kill', { job_id: 'job-1' }),
),
).not.toBeNull()
})

it('returns null for empty or unparseable requests', () => {
expect(
ShellToolView.tryRenderPreview(pendingCall('shell::exec', {})),
).toBeNull()
// A raw string (unrecovered stringified payload) must not parse.
expect(
ShellToolView.tryRenderPreview(pendingCall('shell::exec', 'ls -la')),
).toBeNull()
// argv-as-command is rejected by the schema (server rejects it too).
expect(
ShellToolView.tryRenderPreview(
pendingCall('shell::exec', { command: ['ls', '-la'] }),
),
).toBeNull()
expect(
ShellToolView.tryRenderPreview(pendingCall('shell::exec_bg', {})),
).toBeNull()
expect(
ShellToolView.tryRenderPreview(pendingCall('shell::kill', {})),
).toBeNull()
})
})
20 changes: 17 additions & 3 deletions console/web/src/components/chat/shell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { ShellListView } from './ListView'
import {
isShellFunction,
parseShellErrorDisplay,
shellExecBgRequestSchema,
shellExecRequestSchema,
shellKillRequestSchema,
unwrapEnvelope,
} from './parsers'
import { ShellStatusView } from './StatusView'
Expand Down Expand Up @@ -105,13 +108,24 @@ function tryRenderPreview(
): React.ReactNode | null {
if (!isShellFunction(message.functionId)) return null
const input = unwrapEnvelope(message.input)
// Honor the documented null contract BEFORE building an element: the
// preview components render null for an unparseable request, but a
// non-null element here makes FunctionCallCard treat the preview as
// present and suppress its raw request pane — a held call would show
// no arguments at all.
switch (message.functionId) {
case 'shell::exec':
return <ShellExecPreview input={input} />
return shellExecRequestSchema.safeParse(input).success ? (
<ShellExecPreview input={input} />
) : null
case 'shell::exec_bg':
return <ShellExecBgPreview input={input} />
return shellExecBgRequestSchema.safeParse(input).success ? (
<ShellExecBgPreview input={input} />
) : null
case 'shell::kill':
return <ShellKillPreview input={input} />
return shellKillRequestSchema.safeParse(input).success ? (
<ShellKillPreview input={input} />
) : null
default:
return null
}
Expand Down
14 changes: 12 additions & 2 deletions console/web/src/components/chat/web/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView'
import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers'
import type { FunctionCallMessage } from '@/types/chat'
import { FetchPreview, FetchView } from './FetchView'
import { isWebFunction, unwrapEnvelope } from './parsers'
import {
fetchRequestSchema,
isWebFunction,
safeParseRequest,
unwrapEnvelope,
} from './parsers'

export function WebFunctionIdLabel({ functionId }: { functionId: string }) {
if (!functionId.startsWith('web::')) {
Expand Down Expand Up @@ -48,9 +53,14 @@ function tryRenderPreview(
): React.ReactNode | null {
if (!isWebFunction(message.functionId)) return null
const input = unwrapEnvelope(message.input)
// Parse-check BEFORE building an element: FetchPreview renders null for
// an unparseable request, but a non-null element here makes
// FunctionCallCard suppress its raw request pane (the null contract).
switch (message.functionId) {
case 'web::fetch':
return <FetchPreview input={input} />
return safeParseRequest(fetchRequestSchema, input) ? (
<FetchPreview input={input} />
) : null
default:
return null
}
Expand Down
Loading
Loading