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
43 changes: 42 additions & 1 deletion console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export function ChatView({
break
}
case 'fcall-start': {
/* Defensive turn boundary: if a provider emits fcall-start
before the assistant's message_end, finalize the in-flight
assistant message so the next turn's text doesn't merge
into it. */
if (assistantId) {
onPatchMessage(conversationId, assistantId, {
streaming: false,
})
assistantId = null
assistantBuffer = ''
}
const msg: FunctionCallMessage = {
id: uid(),
role: 'function-call',
Expand Down Expand Up @@ -163,6 +174,12 @@ export function ChatView({
case 'assistant-end': {
if (!assistantId) break
onPatchMessage(conversationId, assistantId, { streaming: false })
/* Per-turn boundary: clear pointers so the next turn's first
token creates a brand-new assistant message instead of
appending to this one. translate.ts emits assistant-end on
every assistant message_end, not just the final agent_end. */
assistantId = null
assistantBuffer = ''
break
}
}
Expand Down Expand Up @@ -204,6 +221,30 @@ export function ChatView({
abortRef.current?.abort()
}, [])

/* "agent is thinking" indicator: stream is active but no streaming
thought/assistant message is currently visible. Covers two gaps:
(a) right after submit, before the first token/thought/fcall arrives;
(b) after each fcall-end, while the harness transitions through
function_finalize → steering_check → next turn's provider stream.
A streaming assistant/thought message renders its own "thinking…"
shimmer when content is empty, so we don't double-render those. */
const isThinking =
isStreaming &&
(() => {
const last =
conversation.messages[conversation.messages.length - 1] ?? null
if (!last) return true
if (last.role === 'user') return true
if (
last.role === 'function-call' &&
!last.running &&
!last.pendingApproval
) {
return true
}
return false
})()

return (
<section className="flex-1 flex flex-col min-w-0 min-h-0">
<header className="flex items-center justify-between px-9 py-3 border-b border-rule">
Expand All @@ -223,7 +264,7 @@ export function ChatView({
</div>
</header>

<MessageList messages={conversation.messages} />
<MessageList messages={conversation.messages} isThinking={isThinking} />

<footer className="px-9 pb-6 pt-2">
<div className="mx-auto max-w-[760px]">
Expand Down
35 changes: 33 additions & 2 deletions console/web/src/components/chat/FunctionCallMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ function isPrimitive(v: unknown): v is Primitive {
)
}

/**
* `null`, `undefined`, `""`, `[]`, and `{}` count as empty so we can render a
* compact "· empty" header instead of a noisy `{}` JSON block. `null` is
* intentionally treated as empty (the user-facing concept is "no input"); the
* primitive `null` rendering would otherwise show the literal text "null".
*/
function isEmptyValue(v: unknown): boolean {
if (v === null || v === undefined) return true
if (typeof v === 'string') return v.length === 0
if (Array.isArray(v)) return v.length === 0
if (typeof v === 'object') {
return Object.keys(v as Record<string, unknown>).length === 0
}
return false
}

function singlePrimitiveField(
v: unknown,
): { key: string; value: Primitive } | null {
Expand Down Expand Up @@ -142,8 +158,23 @@ interface ValuePaneProps {
}

function ValuePane({ label, value, bordered }: ValuePaneProps) {
const primitive = isPrimitive(value)
const single = !primitive ? singlePrimitiveField(value) : null
const empty = isEmptyValue(value)
const primitive = !empty && isPrimitive(value)
const single = !empty && !primitive ? singlePrimitiveField(value) : null

if (empty) {
return (
<div className={cn(bordered && 'border-t border-rule-2')}>
<div className="bg-paper-2 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint">
{label}
<span className="text-ink-ghost normal-case tracking-normal">
{' '}
· empty
</span>
</div>
</div>
)
}

return (
<div className={cn(bordered && 'border-t border-rule-2')}>
Expand Down
19 changes: 14 additions & 5 deletions console/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,28 @@ import { Message } from './Message'

interface MessageListProps {
messages: MessageType[]
/** Show "thinking…" shimmer at the bottom while the agent is between
visible outputs (after submit, or between fcall-end and the next
turn's first token). */
isThinking?: boolean
}

export function MessageList({ messages }: MessageListProps) {
export function MessageList({ messages, isThinking }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)

/* Auto-scroll only when the user is already near the bottom. The effect body
reads layout off refs but the trigger we care about is "messages changed",
so list it explicitly. */
// biome-ignore lint/correctness/useExhaustiveDependencies: messages is the trigger, not a value read in the body.
reads layout off refs but the trigger we care about is "messages changed"
or "thinking flipped", so list both explicitly. */
// biome-ignore lint/correctness/useExhaustiveDependencies: messages and isThinking are the triggers, not values read in the body.
useEffect(() => {
const c = containerRef.current
if (!c) return
const distanceFromBottom = c.scrollHeight - c.scrollTop - c.clientHeight
if (distanceFromBottom < 200) {
bottomRef.current?.scrollIntoView({ block: 'end' })
}
}, [messages])
}, [messages, isThinking])

if (messages.length === 0) {
return <EmptyState />
Expand All @@ -34,6 +38,11 @@ export function MessageList({ messages }: MessageListProps) {
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
{isThinking ? (
<div className="font-mono text-[13px] italic thinking-shimmer text-ink-faint">
thinking…
</div>
) : null}
<div ref={bottomRef} />
</div>
</div>
Expand Down
49 changes: 17 additions & 32 deletions console/web/src/lib/backend/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
* enqueues envelopes whose `session_id` matches.
* 3. Calls `ui::subscribe { browser_id, session_id }` so the harness
* fanout starts forwarding this session's events to us.
* 4. Fires `run::start { session_id, provider, model, system_prompt,
* messages }` against turn-orchestrator.
* 4. Fires `run::start { session_id, provider, model, mode, messages }`
* against turn-orchestrator. The harness owns the system prompt; the
* console only ships the mode and lets the harness prepend the
* matching mode paragraph to its identity preamble.
* 5. Pumps the queue through `translateAgentEvent`, yielding each
* resulting `StreamEvent`. Terminates on the `agent_end` envelope.
*
Expand Down Expand Up @@ -42,40 +44,27 @@ import type { ChatBackend, ChatStreamOptions, StreamEvent } from './types'
interface RunParams {
provider: string
model: string
systemPrompt: string
}

/**
* Map console `Mode` + model selection onto turn-orchestrator's
* `provider` / `model` / `system_prompt` fields.
* Map console model selection onto turn-orchestrator's `provider` / `model`
* fields. The system prompt is no longer the console's concern — the
* harness owns it and is fed `mode` directly on `run::start`.
*
* Model ids from the catalog picker are `provider::<catalog_id>`. Legacy
* heuristic ids (no `::`) still map `claude*` / `gemini*` / default openai.
*/
function resolveRunParams(mode: Mode, model: ModelId): RunParams {
function resolveRunParams(model: ModelId): RunParams {
const parsed = parseCatalogModelKey(model)
let provider: string
let modelId: string
if (parsed) {
provider = parsed.provider
modelId = parsed.id
} else {
modelId = model
provider = model.startsWith('claude')
? 'anthropic'
: model.startsWith('gemini')
? 'google'
: 'openai'
return { provider: parsed.provider, model: parsed.id }
}

const systemPrompt =
mode === 'plan'
? 'You are an analytical planner. Reason briefly, then produce a concise numbered plan; do not execute tools unless asked.'
: mode === 'ask'
? 'You answer the user directly without invoking tools. Be concise; prefer one or two paragraphs.'
: 'You are an autonomous agent. Use the available functions to satisfy the request. Stop when you have a final answer or hit an irrecoverable error.'

return { provider, model: modelId, systemPrompt }
const provider = model.startsWith('claude')
? 'anthropic'
: model.startsWith('gemini')
? 'google'
: 'openai'
return { provider, model }
}

async function* realStream(
Expand Down Expand Up @@ -115,11 +104,7 @@ async function* realStream(
})
subscribed = true

const {
provider,
model: modelId,
systemPrompt,
} = resolveRunParams(mode, model)
const { provider, model: modelId } = resolveRunParams(model)

// Fire-and-forget: run::start kicks off the turn-orchestrator state
// machine; events arrive via the ui::session::event handler above.
Expand All @@ -130,7 +115,7 @@ async function* realStream(
session_id: sessionId,
provider,
model: modelId,
system_prompt: systemPrompt,
mode,
messages: [
{
role: 'user',
Expand Down
11 changes: 10 additions & 1 deletion console/web/src/lib/backend/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,19 @@ export function translateAgentEvent(event: AgentEvent): StreamEvent[] {
case 'agent_start':
case 'turn_start':
case 'turn_end':
case 'message_end':
case 'function_execution_update':
return []

case 'message_end':
// Per-turn boundary: signal `assistant-end` so consumers can finalize
// the streaming assistant message and reset their per-turn pointer.
// Without this, multi-turn agents (text → fcall → text → ...) merge
// every turn's text into the first assistant message.
if (event.message.role === 'assistant') {
return [{ kind: 'assistant-end' }]
}
return []

case 'message_update':
return translateMessageUpdate(event.llm_event)

Expand Down
24 changes: 10 additions & 14 deletions harness-node/src/provider-anthropic/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,24 @@
* resolved Credential, then turns it into an AnthropicConfig.
*/

import type { Credential } from '../auth-credentials/types.js';
import type { ISdk } from '../runtime/iii.js';
import type { WorkerConfig } from './config.js';
import { type AnthropicConfig, configWithCredential } from './types.js';
import type { Credential } from '../auth-credentials/types.js'
import type { ISdk } from '../runtime/iii.js'
import type { WorkerConfig } from './config.js'
import { type AnthropicConfig, configWithCredential } from './types.js'

export async function fetchCredential(iii: ISdk): Promise<Credential> {
const cred = await iii.trigger<unknown, Credential | null>({
function_id: 'auth::get_token',
payload: { provider: 'anthropic' },
timeoutMs: 5_000,
});
})
if (!cred || typeof cred !== 'object' || !('type' in cred)) {
throw new Error('auth::get_token returned no credential for provider=anthropic');
throw new Error('@fn(auth::get_token) returned no credential for provider `anthropic`')
}
return cred;
return cred
}

export async function buildConfig(
iii: ISdk,
worker: WorkerConfig,
model: string,
): Promise<AnthropicConfig> {
const cred = await fetchCredential(iii);
return configWithCredential(model, cred, worker.default_max_tokens, worker.default_api_url);
export async function buildConfig(iii: ISdk, worker: WorkerConfig, model: string): Promise<AnthropicConfig> {
const cred = await fetchCredential(iii)
return configWithCredential(model, cred, worker.default_max_tokens, worker.default_api_url)
}
Loading
Loading