fix: the first build is named and seeded from the welcome chat's stored reply, not its streamed copy
On Windows (run 3, 2026-09-23) the renderer's streamed copy of the setup reply repeated its own chunks. The handoff card read its task and brief from that copy, so the build session was titled "Set up mySet up my games an…" and opened with "Set up Sid up Sid's PC's PC for gaming for gaming…", cut at 240 chars. The backend's stored reply held the clean directive. The card now reads the handoff directive from session.history (the persisted reply) and uses those values; an unreachable history falls back to the rendered attrs. It also waits for the whole reply to stop running: `locked` tracks only the text part, which a later part settles mid-reply.
This commit is contained in:
committed by
Siddharth Balyan
parent
d275e422dc
commit
e458619f23
@@ -14,6 +14,7 @@ import { quarantineHandoffReceipt } from '@/app/contrib/handoff-receipt'
|
||||
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import type { CardProps } from '@/components/onboarding-chat/cards/frame'
|
||||
import { Chip } from '@/components/onboarding-chat/chip'
|
||||
import { readPersistedHandoff } from '@/components/onboarding-chat/persisted-handoff'
|
||||
import {
|
||||
$handoffError,
|
||||
$setupHandoff,
|
||||
@@ -123,6 +124,8 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
const brief = (attrs.brief ?? '').trim().slice(0, 240)
|
||||
const plan = parseHandoffPlan(attrs.plan)
|
||||
const state = useStore($setupHandoff)
|
||||
// `locked` follows this text part; a later part (a tool call, reasoning) settles it while the reply still runs.
|
||||
const replyRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
const receipt = useMemo(() => {
|
||||
try {
|
||||
@@ -139,24 +142,33 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
const completed = receipt.completed
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || !brief || locked || !storedId || !runtimeId || $setupHandoff.get() || completed) {
|
||||
if (!task || !brief || locked || replyRunning || !storedId || !runtimeId || $setupHandoff.get() || completed) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void resolveSessionOwner(storedId)
|
||||
.then(owner => {
|
||||
.then(async owner => {
|
||||
assertSessionOwnerResolved(owner, { method: 'onboarding.handoff', sessionId: storedId })
|
||||
|
||||
const connectionId = isSessionOwnerRoute(owner) ? owner.connectionId : null
|
||||
|
||||
const profile = isSessionOwnerRoute(owner)
|
||||
? owner.profile
|
||||
: owner || $setupSession.get()?.profile || $activeGatewayProfile.get()
|
||||
|
||||
// An unreachable history keeps the rendered attrs: today's behaviour, never a stalled handoff.
|
||||
const persisted = await readPersistedHandoff(connectionId, profile, runtimeId).catch(() => null)
|
||||
const persistedTask = (persisted?.task ?? '').trim().slice(0, 60)
|
||||
const persistedBrief = (persisted?.brief ?? '').trim().slice(0, 240)
|
||||
|
||||
if (!cancelled) {
|
||||
requestSetupHandoff(task, brief, plan, {
|
||||
storedId,
|
||||
runtimeId,
|
||||
connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null,
|
||||
profile: isSessionOwnerRoute(owner)
|
||||
? owner.profile
|
||||
: owner || $setupSession.get()?.profile || $activeGatewayProfile.get()
|
||||
})
|
||||
requestSetupHandoff(
|
||||
persistedTask || task,
|
||||
persistedBrief || brief,
|
||||
persisted ? parseHandoffPlan(persisted.plan) : plan,
|
||||
{ storedId, runtimeId, connectionId, profile }
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -169,7 +181,7 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [brief, locked, plan, task, storedId, runtimeId, completed])
|
||||
}, [brief, locked, plan, replyRunning, task, storedId, runtimeId, completed])
|
||||
|
||||
if (!task || !brief) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SessionHistoryResult } from '@hermes/shared'
|
||||
|
||||
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
|
||||
import { requestGatewayForAgent } from '@/store/gateway'
|
||||
|
||||
/**
|
||||
* The handoff directive as the backend persisted it for the welcome chat. The build session is named and seeded
|
||||
* from these attrs, so they come from the stored reply, not the renderer's streamed copy of it: on Windows the
|
||||
* streamed copy once repeated its own chunks ("Set up mySet up my games…") while the stored reply was intact, and
|
||||
* the handoff carried the garbled task and brief into the new session. Null when no persisted reply holds one.
|
||||
*/
|
||||
export async function readPersistedHandoff(
|
||||
connectionId: null | string,
|
||||
profile: string,
|
||||
runtimeId: string
|
||||
): Promise<null | Readonly<Record<string, string>>> {
|
||||
const history = await requestGatewayForAgent<Partial<SessionHistoryResult>>(
|
||||
connectionId,
|
||||
profile,
|
||||
'session.history',
|
||||
{
|
||||
session_id: runtimeId
|
||||
}
|
||||
)
|
||||
|
||||
const messages = Array.isArray(history?.messages) ? history.messages : []
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
|
||||
if (message?.role !== 'assistant' || typeof message.text !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const segment of segmentTranscriptDirectives(message.text) ?? []) {
|
||||
if (
|
||||
segment.kind === 'directive' &&
|
||||
segment.directive.name === 'onboarding' &&
|
||||
segment.directive.attrs.step === 'handoff'
|
||||
) {
|
||||
return segment.directive.attrs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user