diff --git a/docs/specs/remote-process-log/spec.md b/docs/specs/remote-process-log/spec.md new file mode 100644 index 000000000..73fe4c94f --- /dev/null +++ b/docs/specs/remote-process-log/spec.md @@ -0,0 +1,31 @@ +# Remote Process Log + +## Summary + +Replace the temporary remote status message with an ordered, persistent remote transcript for Telegram and Feishu. Each normal assistant turn keeps: + +- one ordered message sequence derived from assistant blocks +- persistent process-log segments for tool-call progress +- answer segments that stay in the same order as the desktop transcript + +Process segments survive after the turn completes and are not erased. New answer and process phases may create new remote messages instead of rewriting the earliest message in the turn. + +## Acceptance Criteria + +- `RemoteConversationSnapshot` exposes `deliverySegments`, ordered exactly as remote delivery should appear. +- `deliverySegments` contain only `process`, `answer`, and `terminal` segments. +- Process segments contain one line per completed tool-call argument payload using the format `EMOJI raw_tool_name: "preview"`. +- Remote trace preview reuses the desktop tool-summary extraction rule: parse JSON when possible, summarize the first meaningful value, flatten to one line, then truncate for remote delivery. +- Failed tool calls append an extra `āŒ raw_tool_name: "error preview"` line inside the same process segment. +- Consecutive tool calls merge into one process segment until an answer block appears. +- Consecutive answer blocks merge into one answer segment, even if ignored remote-only block types appear between them. +- Telegram and Feishu stop depending on separate trace/content tracks for normal turns and instead sync ordered delivery segments by key. +- New later segments append after earlier ones; completion must not compact all answer text back into the first answer message. +- Tool-only turns keep only the process segment and do not append the generic no-response fallback when the process log already explains the turn. +- Timeout or terminal failure text appends as a trailing terminal segment only when existing answer/process segments do not already express that final state. + +## Non-Goals + +- Showing reasoning content in remote transcripts. +- Showing tool result bodies, search result bodies, or image payloads in the process log. +- Adding a user-facing settings toggle for remote transcript mode in this increment. diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts index 766dce897..97e38159f 100644 --- a/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts @@ -4,6 +4,7 @@ import { FEISHU_INBOUND_DEDUP_TTL_MS, TELEGRAM_STREAM_POLL_INTERVAL_MS, buildFeishuEndpointKey, + type RemoteDeliverySegment, type FeishuInboundMessage, type FeishuOutboundAction, type FeishuRuntimeStatusSnapshot, @@ -12,6 +13,7 @@ import { import { RemoteBindingStore } from '../services/remoteBindingStore' import { FeishuCommandRouter } from '../services/feishuCommandRouter' import type { RemoteConversationExecution } from '../services/remoteConversationRunner' +import { REMOTE_NO_RESPONSE_TEXT } from '../services/remoteBlockRenderer' import { buildFeishuPendingInteractionCard, buildFeishuPendingInteractionText @@ -44,10 +46,12 @@ type FeishuProcessedInboundEntry = { type FeishuRemoteDeliveryState = { sourceMessageId: string - statusMessageId: string | null - contentMessageIds: string[] - lastStatusText: string - lastContentText: string + segments: Array<{ + key: string + kind: 'process' | 'answer' | 'terminal' + messageIds: Array + lastText: string + }> } export class FeishuRuntime { @@ -344,20 +348,10 @@ export class FeishuRuntime { sourceMessageId, deliveryState ) - const statusText = snapshot.statusText?.trim() || '' - const streamText = snapshot.text?.trim() || '' + let deliverySegments = this.getSnapshotDeliverySegments(snapshot, sourceMessageId) if (sourceMessageId) { - deliveryState = deliveryState ?? { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } - - deliveryState = await this.syncStatusMessage(target, endpointKey, deliveryState, statusText) - deliveryState = await this.syncContentText(target, endpointKey, deliveryState, streamText) + deliveryState = deliveryState ?? this.createDeliveryState(sourceMessageId) } if (snapshot.completed) { @@ -365,6 +359,14 @@ export class FeishuRuntime { return } if (snapshot.pendingInteraction) { + if (deliveryState && deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } await this.dispatchOutboundActions( target, [ @@ -379,15 +381,21 @@ export class FeishuRuntime { return } - const finalText = (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + const finalText = this.getFinalDeliveryText(snapshot) + deliverySegments = this.appendTerminalDeliverySegment( + deliverySegments, + sourceMessageId, + finalText + ) if (deliveryState) { - deliveryState = await this.syncFinalContentText( - target, - endpointKey, - deliveryState, - finalText - ) - await this.deleteStatusMessage(deliveryState.statusMessageId) + if (deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) } else if (finalText) { await this.deps.client.sendText(target, finalText) @@ -401,13 +409,12 @@ export class FeishuRuntime { } const timeoutText = 'The current conversation timed out before finishing. Please try again.' if (deliveryState) { - deliveryState = await this.syncFinalContentText( + deliveryState = await this.syncDeliverySegments( target, endpointKey, deliveryState, - timeoutText + this.appendTerminalDeliverySegment(deliverySegments, sourceMessageId, timeoutText) ) - await this.deleteStatusMessage(deliveryState.statusMessageId) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) } else { await this.deps.client.sendText(target, timeoutText) @@ -415,6 +422,15 @@ export class FeishuRuntime { return } + if (deliveryState && deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) } } @@ -427,12 +443,15 @@ export class FeishuRuntime { return { sourceMessageId: state.sourceMessageId, - statusMessageId: typeof state.statusMessageId === 'string' ? state.statusMessageId : null, - contentMessageIds: state.contentMessageIds.filter( - (messageId): messageId is string => typeof messageId === 'string' - ), - lastStatusText: state.lastStatusText, - lastContentText: state.lastContentText + segments: state.segments.map((segment) => ({ + key: segment.key, + kind: segment.kind, + messageIds: segment.messageIds.filter( + (messageId): messageId is string | null => + typeof messageId === 'string' || messageId === null + ), + lastText: segment.lastText + })) } } @@ -444,186 +463,224 @@ export class FeishuRuntime { return state } + private createDeliveryState(sourceMessageId: string): FeishuRemoteDeliveryState { + return { + sourceMessageId, + segments: [] + } + } + private async prepareDeliveryStateForSource( endpointKey: string, sourceMessageId: string | null, state: FeishuRemoteDeliveryState | null ): Promise { if (!state) { - return sourceMessageId - ? { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } - : null + return sourceMessageId ? this.createDeliveryState(sourceMessageId) : null } if (sourceMessageId && state.sourceMessageId === sourceMessageId) { return state } - await this.deleteStatusMessage(state.statusMessageId) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) if (!sourceMessageId) { return null } - return { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } + return this.createDeliveryState(sourceMessageId) } - private async syncStatusMessage( - target: FeishuTransportTarget, - endpointKey: string, - state: FeishuRemoteDeliveryState, - statusText: string - ): Promise { - const normalized = statusText.trim() - if (!normalized) { - return state + private getSnapshotDeliverySegments( + snapshot: Awaited>, + sourceMessageId: string | null + ): RemoteDeliverySegment[] { + if (snapshot.deliverySegments !== undefined) { + return snapshot.deliverySegments.filter((segment) => segment.text.trim().length > 0) + } + + if (!sourceMessageId) { + return [] } - if (!state.statusMessageId) { - const statusMessageId = await this.deps.client.sendText(target, normalized) - return this.rememberDeliveryState(endpointKey, { - ...state, - statusMessageId, - lastStatusText: normalized + const segments: RemoteDeliverySegment[] = [] + const traceText = snapshot.traceText?.trim() || '' + const answerText = snapshot.text?.trim() || '' + + if (traceText) { + segments.push({ + key: `${sourceMessageId}:legacy:process`, + kind: 'process', + text: traceText, + sourceMessageId }) } - if (normalized !== state.lastStatusText) { - await this.deps.client.updateText(state.statusMessageId, normalized) - return this.rememberDeliveryState(endpointKey, { - ...state, - lastStatusText: normalized + if (answerText) { + segments.push({ + key: `${sourceMessageId}:legacy:answer`, + kind: 'answer', + text: answerText, + sourceMessageId }) } - return state + return segments } - private async syncContentText( - target: FeishuTransportTarget, - endpointKey: string, - state: FeishuRemoteDeliveryState, - contentText: string - ): Promise { - const normalized = contentText.trim() - if (!normalized) { - return state - } - - const nextChunks = chunkFeishuText(normalized) - const previousChunks = state.lastContentText ? chunkFeishuText(state.lastContentText) : [] - const contentMessageIds = [...state.contentMessageIds] + private getFinalDeliveryText( + snapshot: Awaited> + ): string { + return (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + } - if (contentMessageIds.length === 0) { - for (const chunk of nextChunks) { - const messageId = await this.deps.client.sendText(target, chunk) - if (messageId) { - contentMessageIds.push(messageId) - } - } - return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds, - lastContentText: normalized - }) + private appendTerminalDeliverySegment( + segments: RemoteDeliverySegment[], + sourceMessageId: string | null, + finalText: string + ): RemoteDeliverySegment[] { + const normalized = finalText.trim() + if (!sourceMessageId || !normalized) { + return segments } - const editableIndex = Math.max(0, contentMessageIds.length - 1) - const retainedCount = Math.min(contentMessageIds.length, nextChunks.length) - - for (let index = editableIndex; index < retainedCount; index += 1) { - if (previousChunks[index] === nextChunks[index]) { - continue - } + const lastAnswerSegment = [...segments].reverse().find((segment) => segment.kind === 'answer') + if (lastAnswerSegment?.text === normalized) { + return segments + } - await this.deps.client.updateText(contentMessageIds[index], nextChunks[index]) + if (normalized === REMOTE_NO_RESPONSE_TEXT && segments.length > 0) { + return segments } - for (let index = contentMessageIds.length; index < nextChunks.length; index += 1) { - const messageId = await this.deps.client.sendText(target, nextChunks[index]) - if (messageId) { - contentMessageIds.push(messageId) + return [ + ...segments, + { + key: `${sourceMessageId}:terminal`, + kind: 'terminal', + text: normalized, + sourceMessageId } + ] + } + + private isDeliveryStateCompatible( + state: FeishuRemoteDeliveryState, + segments: RemoteDeliverySegment[] + ): boolean { + if (segments.length < state.segments.length) { + return false } - return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds, - lastContentText: normalized - }) + return state.segments.every((segment, index) => segments[index]?.key === segment.key) } - private async syncFinalContentText( + private async syncDeliverySegments( target: FeishuTransportTarget, endpointKey: string, state: FeishuRemoteDeliveryState, - finalText: string + segments: RemoteDeliverySegment[] ): Promise { - const normalized = finalText.trim() - if (!normalized) { + if (segments.length === 0) { return state } - const nextChunks = chunkFeishuText(normalized) - const previousChunks = state.lastContentText ? chunkFeishuText(state.lastContentText) : [] - const contentMessageIds = [...state.contentMessageIds] + let nextState = state + if (!this.isDeliveryStateCompatible(nextState, segments)) { + this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) + nextState = this.createDeliveryState(state.sourceMessageId) + } - for (let index = 0; index < nextChunks.length; index += 1) { - if (index < contentMessageIds.length) { - if (previousChunks[index] === nextChunks[index]) { - continue - } + const syncedSegments: FeishuRemoteDeliveryState['segments'] = [] - await this.deps.client.updateText(contentMessageIds[index], nextChunks[index]) - continue + for (const [index, segment] of segments.entries()) { + const syncedSegment = await this.syncDeliverySegment( + target, + nextState.segments[index] ?? null, + segment + ) + syncedSegments.push(syncedSegment) + } + + return this.rememberDeliveryState(endpointKey, { + sourceMessageId: nextState.sourceMessageId, + segments: syncedSegments + }) + } + + private async syncDeliverySegment( + target: FeishuTransportTarget, + existing: FeishuRemoteDeliveryState['segments'][number] | null, + segment: RemoteDeliverySegment + ): Promise { + const normalized = segment.text.trim() + const nextChunks = chunkFeishuText(normalized) + + if (!existing) { + const messageIds: Array = [] + for (const chunk of nextChunks) { + const messageId = await this.deps.client.sendText(target, chunk) + messageIds.push(messageId ?? null) } - const messageId = await this.deps.client.sendText(target, nextChunks[index]) - if (messageId) { - contentMessageIds.push(messageId) + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized } } - for (const messageId of contentMessageIds.slice(nextChunks.length)) { - await this.deleteMessage(messageId) + const previousChunks = existing.lastText ? chunkFeishuText(existing.lastText) : [] + if ( + nextChunks.length < existing.messageIds.length || + previousChunks.length < existing.messageIds.length || + previousChunks + .slice(0, Math.max(0, existing.messageIds.length - 1)) + .some((chunk, index) => chunk !== nextChunks[index]) + ) { + const messageIds: Array = [] + for (const chunk of nextChunks) { + const messageId = await this.deps.client.sendText(target, chunk) + messageIds.push(messageId ?? null) + } + + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized + } } - return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds: contentMessageIds.slice(0, nextChunks.length), - lastContentText: normalized - }) - } + const messageIds = [...existing.messageIds] + const editableIndex = Math.max(0, messageIds.length - 1) + const retainedCount = Math.min(messageIds.length, nextChunks.length) - private async deleteStatusMessage(messageId: string | null): Promise { - if (!messageId) { - return + for (let index = editableIndex; index < retainedCount; index += 1) { + if (previousChunks[index] === nextChunks[index]) { + continue + } + + const messageId = messageIds[index] + if (!messageId) { + continue + } + + await this.deps.client.updateText(messageId, nextChunks[index]) } - await this.deleteMessage(messageId) - } + for (let index = messageIds.length; index < nextChunks.length; index += 1) { + const messageId = await this.deps.client.sendText(target, nextChunks[index]) + messageIds.push(messageId ?? null) + } - private async deleteMessage(messageId: string): Promise { - try { - await this.deps.client.deleteMessage(messageId) - } catch (error) { - console.warn('[FeishuRuntime] Failed to delete message:', { - messageId, - error - }) + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized } } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts index b72f48ebe..224ed63ae 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -24,10 +24,12 @@ import { export interface RemoteDeliveryState { sourceMessageId: string - statusMessageId: string | number | null - contentMessageIds: Array - lastStatusText: string - lastContentText: string + segments: Array<{ + key: string + kind: 'process' | 'answer' | 'terminal' + messageIds: Array + lastText: string + }> } export class RemoteBindingStore { @@ -407,10 +409,12 @@ export class RemoteBindingStore { rememberRemoteDeliveryState(endpointKey: string, state: RemoteDeliveryState): void { this.remoteDeliveryStates.set(endpointKey, { sourceMessageId: state.sourceMessageId, - statusMessageId: state.statusMessageId, - contentMessageIds: [...state.contentMessageIds], - lastStatusText: state.lastStatusText, - lastContentText: state.lastContentText + segments: state.segments.map((segment) => ({ + key: segment.key, + kind: segment.kind, + messageIds: [...segment.messageIds], + lastText: segment.lastText + })) }) } @@ -422,10 +426,12 @@ export class RemoteBindingStore { return { sourceMessageId: state.sourceMessageId, - statusMessageId: state.statusMessageId, - contentMessageIds: [...state.contentMessageIds], - lastStatusText: state.lastStatusText, - lastContentText: state.lastContentText + segments: state.segments.map((segment) => ({ + key: segment.key, + kind: segment.kind, + messageIds: [...segment.messageIds], + lastText: segment.lastText + })) } } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBlockRenderer.ts b/src/main/presenter/remoteControlPresenter/services/remoteBlockRenderer.ts index 02ada6dcd..61b091398 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBlockRenderer.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBlockRenderer.ts @@ -1,14 +1,17 @@ import type { AssistantMessageBlock } from '@shared/types/agent-interface' import type { SearchResult } from '@shared/types/core/search' -import type { RemoteRenderableBlock } from '../types' +import { summarizeToolCallPreview } from '@shared/lib/toolCallSummary' +import type { RemoteDeliverySegment, RemoteRenderableBlock } from '../types' const TOOL_ARGS_PREVIEW_LIMIT = 1_200 const TOOL_RESULT_PREVIEW_LIMIT = 1_600 const SEARCH_RESULT_LIMIT = 5 const SEARCH_SNIPPET_LIMIT = 220 +const TRACE_PREVIEW_LIMIT = 160 const DEFAULT_REMOTE_STATUS_TEXT = 'Running...' export const REMOTE_WAITING_STATUS_TEXT = 'Waiting for your response...' const DEFAULT_REMOTE_ERROR_TEXT = 'The conversation ended with an error.' +export const REMOTE_NO_RESPONSE_TEXT = 'No assistant response was produced.' const normalizeText = (value: string | undefined | null): string => (value ?? '').replace(/\r\n/g, '\n').trim() @@ -200,6 +203,113 @@ const formatImageNoticeBlock = (block: AssistantMessageBlock): string => { const formatErrorBlock = (content: string): string => buildSection('[Error]', content) +const truncateSingleLine = (value: string, limit: number): string => { + const normalized = value.trim() + if (!normalized) { + return '' + } + + if (normalized.length <= limit) { + return normalized + } + + return `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...` +} + +const escapeTracePreview = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + +const getTracePreview = ( + value: string | undefined | null, + fallback: string = '(none)', + limit: number = TRACE_PREVIEW_LIMIT +): string => { + const preview = summarizeToolCallPreview(value) || fallback + return escapeTracePreview(truncateSingleLine(preview, limit)) +} + +const getTraceEmoji = (toolName: string): string => { + const normalized = toolName.trim().toLowerCase() + + if (!normalized) { + return 'šŸ› ' + } + + if (normalized.includes('cron') || normalized.includes('schedule')) { + return 'ā°' + } + + if ( + normalized === 'grep' || + normalized === 'find' || + normalized.includes('search') || + normalized.includes('grep') + ) { + return 'šŸ”Ž' + } + + if ( + normalized === 'read' || + normalized === 'cat' || + normalized.includes('read') || + normalized.includes('open') + ) { + return 'šŸ“–' + } + + if ( + normalized === 'write' || + normalized === 'edit' || + normalized.includes('write') || + normalized.includes('edit') + ) { + return 'šŸ“' + } + + if (normalized === 'ls' || normalized.includes('list') || normalized.includes('directory')) { + return 'šŸ“‚' + } + + if ( + normalized === 'exec' || + normalized === 'process' || + normalized.includes('exec') || + normalized.includes('process') || + normalized.includes('terminal') || + normalized.includes('shell') || + normalized.includes('command') + ) { + return 'šŸ’»' + } + + return 'šŸ› ' +} + +const getProcessLogLines = (block: AssistantMessageBlock): string[] => { + if (block.type !== 'tool_call' || !isToolCallArgsComplete(block)) { + return [] + } + + const toolName = normalizeText(block.tool_call?.name) || 'unknown_tool' + const lines = [ + `${getTraceEmoji(toolName)} ${toolName}: "${getTracePreview(block.tool_call?.params)}"` + ] + + if (block.status === 'error') { + lines.push( + `āŒ ${toolName}: "${getTracePreview(block.tool_call?.response || block.content, 'error')}"` + ) + } + + return lines +} + +export const buildRemoteTraceText = (blocks: AssistantMessageBlock[]): string => + blocks + .flatMap((block) => getProcessLogLines(block)) + .join('\n') + .trim() + const isRenderableNarrativeBlock = (block: AssistantMessageBlock): boolean => (block.type === 'content' || block.type === 'reasoning_content') && block.status !== 'pending' && @@ -248,6 +358,78 @@ export const buildRemoteStreamText = (blocks: AssistantMessageBlock[]): string = .join('\n\n') .trim() +export const buildRemoteDeliverySegments = ( + messageId: string, + blocks: AssistantMessageBlock[] +): RemoteDeliverySegment[] => { + const segments: RemoteDeliverySegment[] = [] + let current: { + key: string + kind: 'process' | 'answer' + parts: string[] + } | null = null + + const flushCurrent = () => { + if (!current) { + return + } + + const text = current.parts.join(current.kind === 'process' ? '\n' : '\n\n').trim() + if (!text) { + current = null + return + } + + segments.push({ + key: current.key, + kind: current.kind, + text, + sourceMessageId: messageId + }) + current = null + } + + for (const [index, block] of blocks.entries()) { + const processLines = getProcessLogLines(block) + if (processLines.length > 0) { + if (!current || current.kind !== 'process') { + flushCurrent() + current = { + key: `${messageId}:${index}:process`, + kind: 'process', + parts: [] + } + } + current.parts.push(...processLines) + continue + } + + if (block.type !== 'content') { + continue + } + + const content = normalizeText(block.content) + if (!content) { + continue + } + + if (!current || current.kind !== 'answer') { + flushCurrent() + current = { + key: `${messageId}:${index}:answer`, + kind: 'answer', + parts: [] + } + } + + current.parts.push(content) + } + + flushCurrent() + + return segments +} + const isToolCallArgsComplete = (block: AssistantMessageBlock): boolean => block.status !== 'pending' || block.extra?.toolCallArgsComplete === true diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index 82913a99b..c18cb1836 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -15,6 +15,7 @@ import type { import type { AgentRuntimePresenter } from '../../agentRuntimePresenter' import { TELEGRAM_RECENT_SESSION_LIMIT, + type RemoteDeliverySegment, TELEGRAM_STREAM_POLL_INTERVAL_MS, type RemoteEndpointBindingMeta, type RemoteRenderableBlock, @@ -23,11 +24,14 @@ import { } from '../types' import { safeParseAssistantBlocks } from '../telegram/telegramOutbound' import { + REMOTE_NO_RESPONSE_TEXT, REMOTE_WAITING_STATUS_TEXT, + buildRemoteDeliverySegments, buildRemoteDraftText, buildRemoteFinalText, buildRemoteFullText, buildRemoteRenderableBlocks, + buildRemoteTraceText, buildRemoteStreamText, buildRemoteStatusText } from './remoteBlockRenderer' @@ -41,6 +45,8 @@ const sleep = async (ms: number): Promise => { export interface RemoteConversationSnapshot { messageId: string | null text: string + traceText?: string + deliverySegments?: RemoteDeliverySegment[] statusText?: string finalText?: string draftText?: string @@ -460,6 +466,8 @@ export class RemoteConversationRunner { return { messageId: null, text: 'The bound session no longer exists.', + traceText: '', + deliverySegments: [], statusText: '', finalText: 'The bound session no longer exists.', draftText: '', @@ -490,11 +498,13 @@ export class RemoteConversationRunner { return { messageId: null, text: completed ? 'No assistant response was produced.' : '', + traceText: '', + deliverySegments: [], statusText: completed ? '' : buildRemoteStatusText([]), - finalText: completed ? 'No assistant response was produced.' : '', + finalText: completed ? REMOTE_NO_RESPONSE_TEXT : '', draftText: '', renderBlocks: [], - fullText: completed ? 'No assistant response was produced.' : '', + fullText: completed ? REMOTE_NO_RESPONSE_TEXT : '', completed, pendingInteraction: null } @@ -502,7 +512,9 @@ export class RemoteConversationRunner { const blocks = safeParseAssistantBlocks(trackedMessage.content) const streamText = buildRemoteStreamText(blocks) + const traceText = buildRemoteTraceText(blocks) const draftText = buildRemoteDraftText(blocks) + const deliverySegments = buildRemoteDeliverySegments(trackedMessage.id, blocks) const renderBlocks = await buildRemoteRenderableBlocks({ messageId: trackedMessage.id, blocks, @@ -520,7 +532,7 @@ export class RemoteConversationRunner { preferTerminalError: trackedMessage.status === 'error', fallbackErrorText: trackedMessage.status === 'error' ? 'The conversation ended with an error.' : undefined, - fallbackNoResponseText: 'No assistant response was produced.' + fallbackNoResponseText: REMOTE_NO_RESPONSE_TEXT }) const completed = Boolean(pendingInteraction) || @@ -534,6 +546,8 @@ export class RemoteConversationRunner { return { messageId: trackedMessage.id, text: streamText, + traceText, + deliverySegments, statusText: pendingInteraction ? REMOTE_WAITING_STATUS_TEXT : statusText, finalText, draftText, diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts index 6d4722f1a..a6fe8bce3 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -4,6 +4,7 @@ import { TELEGRAM_REMOTE_POLL_TIMEOUT_SEC, TELEGRAM_STREAM_POLL_INTERVAL_MS, TELEGRAM_TYPING_DELAY_MS, + type RemoteDeliverySegment, type RemotePendingInteraction, type TelegramInboundMessage, type TelegramOutboundAction, @@ -11,11 +12,13 @@ import { type TelegramTransportTarget } from '../types' import { RemoteBindingStore } from '../services/remoteBindingStore' +import { REMOTE_NO_RESPONSE_TEXT } from '../services/remoteBlockRenderer' import { RemoteCommandRouter, type RemoteCommandRouteContinuation, type RemoteCommandRouteResult } from '../services/remoteCommandRouter' +import type { RemoteConversationExecution } from '../services/remoteConversationRunner' import { chunkTelegramText } from './telegramOutbound' import { buildTelegramPendingInteractionPrompt } from './telegramInteractionPrompt' import { TelegramApiRequestError, TelegramClient, type TelegramRawUpdate } from './telegramClient' @@ -56,10 +59,12 @@ type TelegramPollerDeps = { type TelegramRemoteDeliveryState = { sourceMessageId: string - statusMessageId: number | null - contentMessageIds: number[] - lastStatusText: string - lastContentText: string + segments: Array<{ + key: string + kind: 'process' | 'answer' | 'terminal' + messageIds: Array + lastText: string + }> } export class TelegramPoller { @@ -325,49 +330,62 @@ export class TelegramPoller { const sourceMessageId = snapshot.messageId ?? execution.eventId ?? null let deliveryState = this.getStoredDeliveryState(endpointKey) deliveryState = await this.prepareDeliveryStateForSource( - target, endpointKey, sourceMessageId, deliveryState ) - const statusText = snapshot.statusText?.trim() || '' - const streamText = snapshot.text?.trim() || '' + let deliverySegments = this.getSnapshotDeliverySegments(snapshot, sourceMessageId) if (sourceMessageId) { - deliveryState = deliveryState ?? { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } - - deliveryState = await this.syncStatusMessage(target, endpointKey, deliveryState, statusText) - deliveryState = await this.syncContentText(target, endpointKey, deliveryState, streamText) + deliveryState = deliveryState ?? this.createDeliveryState(sourceMessageId) } if (snapshot.completed) { if (snapshot.pendingInteraction) { + if (deliveryState && deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } await this.sendPendingInteractionPrompt(target, snapshot.pendingInteraction) return } - const finalText = (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + const finalText = this.getFinalDeliveryText(snapshot) + deliverySegments = this.appendTerminalDeliverySegment( + deliverySegments, + sourceMessageId, + finalText + ) + if (deliveryState) { - deliveryState = await this.syncFinalContentText( - target, - endpointKey, - deliveryState, - finalText - ) - await this.deleteStatusMessage(target, deliveryState.statusMessageId) + if (deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) - } else { + } else if (finalText) { await this.sendChunkedMessage(target, finalText) } return } + if (deliveryState && deliverySegments.length > 0) { + deliveryState = await this.syncDeliverySegments( + target, + endpointKey, + deliveryState, + deliverySegments + ) + } + if (!typingSent && Date.now() - startedAt >= TELEGRAM_TYPING_DELAY_MS) { typingSent = true await this.sendTyping(target) @@ -385,12 +403,15 @@ export class TelegramPoller { return { sourceMessageId: state.sourceMessageId, - statusMessageId: typeof state.statusMessageId === 'number' ? state.statusMessageId : null, - contentMessageIds: state.contentMessageIds.filter( - (messageId): messageId is number => typeof messageId === 'number' - ), - lastStatusText: state.lastStatusText, - lastContentText: state.lastContentText + segments: state.segments.map((segment) => ({ + key: segment.key, + kind: segment.kind, + messageIds: segment.messageIds.filter( + (messageId): messageId is number | null => + typeof messageId === 'number' || messageId === null + ), + lastText: segment.lastText + })) } } @@ -402,201 +423,227 @@ export class TelegramPoller { return state } + private createDeliveryState(sourceMessageId: string): TelegramRemoteDeliveryState { + return { + sourceMessageId, + segments: [] + } + } + private async prepareDeliveryStateForSource( - target: TelegramTransportTarget, endpointKey: string, sourceMessageId: string | null, state: TelegramRemoteDeliveryState | null ): Promise { if (!state) { - return sourceMessageId - ? { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } - : null + return sourceMessageId ? this.createDeliveryState(sourceMessageId) : null } if (sourceMessageId && state.sourceMessageId === sourceMessageId) { return state } - await this.deleteStatusMessage(target, state.statusMessageId) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) if (!sourceMessageId) { return null } - return { - sourceMessageId, - statusMessageId: null, - contentMessageIds: [], - lastStatusText: '', - lastContentText: '' - } + return this.createDeliveryState(sourceMessageId) } - private async syncStatusMessage( - target: TelegramTransportTarget, - endpointKey: string, - state: TelegramRemoteDeliveryState, - statusText: string - ): Promise { - const normalized = statusText.trim() - if (!normalized) { - return state + private getSnapshotDeliverySegments( + snapshot: Awaited>, + sourceMessageId: string | null + ): RemoteDeliverySegment[] { + if (snapshot.deliverySegments !== undefined) { + return snapshot.deliverySegments.filter((segment) => segment.text.trim().length > 0) } - if (state.statusMessageId == null) { - const statusMessageId = await this.deps.client.sendMessage(target, normalized) - return this.rememberDeliveryState(endpointKey, { - ...state, - statusMessageId, - lastStatusText: normalized - }) + if (!sourceMessageId) { + return [] } - if (normalized !== state.lastStatusText) { - await this.editMessageText(target, { - type: 'editMessageText', - messageId: state.statusMessageId, - text: normalized, - replyMarkup: null + const segments: RemoteDeliverySegment[] = [] + const traceText = snapshot.traceText?.trim() || '' + const answerText = snapshot.text?.trim() || '' + + if (traceText) { + segments.push({ + key: `${sourceMessageId}:legacy:process`, + kind: 'process', + text: traceText, + sourceMessageId }) - return this.rememberDeliveryState(endpointKey, { - ...state, - lastStatusText: normalized + } + + if (answerText) { + segments.push({ + key: `${sourceMessageId}:legacy:answer`, + kind: 'answer', + text: answerText, + sourceMessageId }) } - return state + return segments } - private async syncContentText( - target: TelegramTransportTarget, - endpointKey: string, - state: TelegramRemoteDeliveryState, - contentText: string - ): Promise { - const normalized = contentText.trim() - if (!normalized) { - return state - } + private getFinalDeliveryText( + snapshot: Awaited> + ): string { + return (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + } - const nextChunks = chunkTelegramText(normalized) - const previousChunks = state.lastContentText ? chunkTelegramText(state.lastContentText) : [] - const contentMessageIds = [...state.contentMessageIds] + private appendTerminalDeliverySegment( + segments: RemoteDeliverySegment[], + sourceMessageId: string | null, + finalText: string + ): RemoteDeliverySegment[] { + const normalized = finalText.trim() + if (!sourceMessageId || !normalized) { + return segments + } - if (contentMessageIds.length === 0) { - for (const chunk of nextChunks) { - contentMessageIds.push(await this.deps.client.sendMessage(target, chunk)) - } - return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds, - lastContentText: normalized - }) + const lastAnswerSegment = [...segments].reverse().find((segment) => segment.kind === 'answer') + if (lastAnswerSegment?.text === normalized) { + return segments } - const editableIndex = Math.max(0, contentMessageIds.length - 1) - const retainedCount = Math.min(contentMessageIds.length, nextChunks.length) + if (normalized === REMOTE_NO_RESPONSE_TEXT && segments.length > 0) { + return segments + } - for (let index = editableIndex; index < retainedCount; index += 1) { - if (previousChunks[index] === nextChunks[index]) { - continue + return [ + ...segments, + { + key: `${sourceMessageId}:terminal`, + kind: 'terminal', + text: normalized, + sourceMessageId } + ] + } - await this.editMessageText(target, { - type: 'editMessageText', - messageId: contentMessageIds[index], - text: nextChunks[index], - replyMarkup: null - }) - } - - for (let index = contentMessageIds.length; index < nextChunks.length; index += 1) { - contentMessageIds.push(await this.deps.client.sendMessage(target, nextChunks[index])) + private isDeliveryStateCompatible( + state: TelegramRemoteDeliveryState, + segments: RemoteDeliverySegment[] + ): boolean { + if (segments.length < state.segments.length) { + return false } - return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds, - lastContentText: normalized - }) + return state.segments.every((segment, index) => segments[index]?.key === segment.key) } - private async syncFinalContentText( + private async syncDeliverySegments( target: TelegramTransportTarget, endpointKey: string, state: TelegramRemoteDeliveryState, - finalText: string + segments: RemoteDeliverySegment[] ): Promise { - const normalized = finalText.trim() - if (!normalized) { + if (segments.length === 0) { return state } - const nextChunks = chunkTelegramText(normalized) - const previousChunks = state.lastContentText ? chunkTelegramText(state.lastContentText) : [] - const contentMessageIds = [...state.contentMessageIds] - - for (let index = 0; index < nextChunks.length; index += 1) { - if (index < contentMessageIds.length) { - if (previousChunks[index] === nextChunks[index]) { - continue - } - - await this.editMessageText(target, { - type: 'editMessageText', - messageId: contentMessageIds[index], - text: nextChunks[index], - replyMarkup: null - }) - continue - } - - contentMessageIds.push(await this.deps.client.sendMessage(target, nextChunks[index])) + let nextState = state + if (!this.isDeliveryStateCompatible(nextState, segments)) { + this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) + nextState = this.createDeliveryState(state.sourceMessageId) } - for (const messageId of contentMessageIds.slice(nextChunks.length)) { - await this.deleteMessage(target, messageId) + const syncedSegments: TelegramRemoteDeliveryState['segments'] = [] + + for (const [index, segment] of segments.entries()) { + const syncedSegment = await this.syncDeliverySegment( + target, + nextState.segments[index] ?? null, + segment + ) + syncedSegments.push(syncedSegment) } return this.rememberDeliveryState(endpointKey, { - ...state, - contentMessageIds: contentMessageIds.slice(0, nextChunks.length), - lastContentText: normalized + sourceMessageId: nextState.sourceMessageId, + segments: syncedSegments }) } - private async deleteStatusMessage( + private async syncDeliverySegment( target: TelegramTransportTarget, - messageId: number | null - ): Promise { - if (messageId == null) { - return + existing: TelegramRemoteDeliveryState['segments'][number] | null, + segment: RemoteDeliverySegment + ): Promise { + const normalized = segment.text.trim() + const nextChunks = chunkTelegramText(normalized) + + if (!existing) { + const messageIds: number[] = [] + for (const chunk of nextChunks) { + messageIds.push(await this.deps.client.sendMessage(target, chunk)) + } + + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized + } } - await this.deleteMessage(target, messageId) - } + const previousChunks = existing.lastText ? chunkTelegramText(existing.lastText) : [] + if ( + nextChunks.length < existing.messageIds.length || + previousChunks.length < existing.messageIds.length || + previousChunks + .slice(0, Math.max(0, existing.messageIds.length - 1)) + .some((chunk, index) => chunk !== nextChunks[index]) + ) { + const messageIds: number[] = [] + for (const chunk of nextChunks) { + messageIds.push(await this.deps.client.sendMessage(target, chunk)) + } - private async deleteMessage(target: TelegramTransportTarget, messageId: number): Promise { - try { - await this.deps.client.deleteMessage({ - target, - messageId - }) - } catch (error) { - console.warn('[TelegramPoller] Failed to delete message:', { - target, + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized + } + } + + const messageIds = [...existing.messageIds] + const editableIndex = Math.max(0, messageIds.length - 1) + const retainedCount = Math.min(messageIds.length, nextChunks.length) + + for (let index = editableIndex; index < retainedCount; index += 1) { + if (previousChunks[index] === nextChunks[index]) { + continue + } + + const messageId = messageIds[index] + if (!messageId) { + continue + } + + await this.editMessageText(target, { + type: 'editMessageText', messageId, - error + text: nextChunks[index], + replyMarkup: null }) } + + for (let index = messageIds.length; index < nextChunks.length; index += 1) { + messageIds.push(await this.deps.client.sendMessage(target, nextChunks[index])) + } + + return { + key: segment.key, + kind: segment.kind, + messageIds, + lastText: normalized + } } private async sendTyping(target: TelegramTransportTarget): Promise { diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index f2cfb3ffa..c94565c6c 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -295,6 +295,13 @@ export interface RemoteRenderableBlock { sourceMessageId: string } +export interface RemoteDeliverySegment { + key: string + kind: 'process' | 'answer' | 'terminal' + text: string + sourceMessageId: string +} + export type TelegramOutboundAction = | { type: 'sendMessage' diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index c9c86c388..2283f9102 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -182,6 +182,7 @@ import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { CodeBlockNode } from 'markstream-vue' +import { summarizeToolCallPreview } from '@shared/lib/toolCallSummary' import { useThemeStore } from '@/stores/theme' import { useSessionStore } from '@/stores/ui/session' import { getLanguageFromFilename } from '@shared/utils/codeLanguage' @@ -203,39 +204,6 @@ type ExpansionSource = 'auto' | 'manual' | null const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) -const normalizeInlineText = (value: string): string => value.replace(/\s+/g, ' ').trim() - -const extractFirstSummaryValue = (value: unknown): unknown => { - if (Array.isArray(value)) { - return value.length > 0 ? value[0] : '' - } - if (isRecord(value)) { - const entries = Object.entries(value) - return entries.length > 0 ? entries[0][1] : '' - } - return value -} - -const formatSummaryValue = (value: unknown): string => { - if (typeof value === 'string') { - return normalizeInlineText(value) - } - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { - return String(value) - } - if (value === null) { - return 'null' - } - if (value === undefined) { - return '' - } - try { - return normalizeInlineText(JSON.stringify(value)) - } catch { - return normalizeInlineText(String(value)) - } -} - const coerceNumericParam = (value: unknown): number | null => { if (typeof value === 'number' && Number.isFinite(value)) { return value @@ -378,10 +346,7 @@ const summaryText = computed(() => { const raw = paramsText.value.trim() if (!raw) return '' - if (!parsedParams.value.isJson) { - return normalizeInlineText(raw) - } - return formatSummaryValue(extractFirstSummaryValue(parsedParams.value.value)) + return summarizeToolCallPreview(raw) }) const subagentTasks = computed(() => { diff --git a/src/shared/lib/toolCallSummary.ts b/src/shared/lib/toolCallSummary.ts new file mode 100644 index 000000000..ae7150d98 --- /dev/null +++ b/src/shared/lib/toolCallSummary.ts @@ -0,0 +1,54 @@ +const normalizeInlineText = (value: string): string => value.replace(/\s+/g, ' ').trim() + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const extractFirstSummaryValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : '' + } + + if (isRecord(value)) { + const entries = Object.entries(value) + return entries.length > 0 ? entries[0][1] : '' + } + + return value +} + +const formatSummaryValue = (value: unknown): string => { + if (typeof value === 'string') { + return normalizeInlineText(value) + } + + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + + if (value === null) { + return 'null' + } + + if (value === undefined) { + return '' + } + + try { + return normalizeInlineText(JSON.stringify(value)) + } catch { + return normalizeInlineText(String(value)) + } +} + +export const summarizeToolCallPreview = (value: string | undefined | null): string => { + const raw = value?.trim() ?? '' + if (!raw) { + return '' + } + + try { + return formatSummaryValue(extractFirstSummaryValue(JSON.parse(raw) as unknown)) + } catch { + return normalizeInlineText(raw) + } +} diff --git a/test/main/presenter/remoteControlPresenter/feishuRuntime.test.ts b/test/main/presenter/remoteControlPresenter/feishuRuntime.test.ts index 986ab9b3f..fc1102240 100644 --- a/test/main/presenter/remoteControlPresenter/feishuRuntime.test.ts +++ b/test/main/presenter/remoteControlPresenter/feishuRuntime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { FeishuRuntime } from '@/presenter/remoteControlPresenter/feishu/feishuRuntime' import { FEISHU_CONVERSATION_POLL_TIMEOUT_MS, + FEISHU_OUTBOUND_TEXT_LIMIT, TELEGRAM_STREAM_POLL_INTERVAL_MS, type FeishuInboundMessage } from '@/presenter/remoteControlPresenter/types' @@ -72,7 +73,10 @@ const createHarness = async (options?: { logger?: { error: (...params: unknown[] rememberRemoteDeliveryState: vi.fn((endpointKey: string, state: any) => { deliveryStates.set(endpointKey, { ...state, - contentMessageIds: [...state.contentMessageIds] + segments: state.segments.map((segment: any) => ({ + ...segment, + messageIds: [...segment.messageIds] + })) }) }), clearRemoteDeliveryState: vi.fn((endpointKey: string) => { @@ -104,7 +108,7 @@ const createHarness = async (options?: { logger?: { error: (...params: unknown[] } describe('FeishuRuntime', () => { - it('streams answer text beside a temporary status message', async () => { + it('streams answer text beside a persistent trace log', async () => { vi.useFakeTimers() try { @@ -119,6 +123,15 @@ describe('FeishuRuntime', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: '', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + } + ], statusText: 'Running: thinking...', finalText: '', draftText: '', @@ -127,9 +140,50 @@ describe('FeishuRuntime', () => { completed: false, pendingInteraction: null }) + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Draft answer', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + text: 'Draft answer', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + draftText: '', + renderBlocks: [], + fullText: '[Answer]\nDraft answer', + completed: false, + pendingInteraction: null + }) .mockResolvedValue({ messageId: 'msg-1', text: 'Draft answer', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + text: 'Final answer', + sourceMessageId: 'msg-1' + } + ], statusText: 'Running: writing...', finalText: 'Final answer', draftText: '', @@ -154,7 +208,7 @@ describe('FeishuRuntime', () => { threadId: null, replyToMessageId: 'om_incremental' }, - 'Running: thinking...' + 'šŸ’» shell_command: "git status"' ) }) @@ -173,14 +227,23 @@ describe('FeishuRuntime', () => { 'feishu:oc_1:root', expect.objectContaining({ sourceMessageId: 'msg-1', - statusMessageId: 'om_bot_1', - contentMessageIds: ['om_bot_2'], - lastStatusText: 'Running: writing...', - lastContentText: 'Draft answer' + segments: [ + { + key: 'msg-1:0:process', + kind: 'process', + messageIds: ['om_bot_1'], + lastText: 'šŸ’» shell_command: "git status"' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + messageIds: ['om_bot_2'], + lastText: 'Draft answer' + } + ] }) ) expect(harness.client.updateText).toHaveBeenCalledWith('om_bot_2', 'Final answer') - expect(harness.client.deleteMessage).toHaveBeenCalledWith('om_bot_1') expect(harness.bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith( 'feishu:oc_1:root' ) @@ -192,6 +255,95 @@ describe('FeishuRuntime', () => { } }) + it('appends terminal text after a partial answer when the final state differs', async () => { + vi.useFakeTimers() + + try { + const harness = await createHarness() + harness.router.handleMessage.mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi + .fn() + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Partial answer', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Partial answer', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValue({ + messageId: 'msg-1', + text: 'Partial answer', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Partial answer', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: 'The conversation ended with an error.', + completed: true, + pendingInteraction: null + }) + } + }) + + await harness.onMessage({ + parsed: createParsedMessage({ + messageId: 'om_partial_error' + }) + }) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_partial_error' + }, + 'Partial answer' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_partial_error' + }, + 'The conversation ended with an error.' + ) + expect(harness.client.updateText).not.toHaveBeenCalledWith( + 'om_bot_1', + 'The conversation ended with an error.' + ) + }) + + await harness.runtime.stop() + } finally { + vi.useRealTimers() + } + }) + it('keeps the latest answer chunk editable when streamed text exceeds the platform limit', async () => { vi.useFakeTimers() @@ -210,6 +362,7 @@ describe('FeishuRuntime', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: firstText, + traceText: '', statusText: 'Running: writing...', finalText: '', completed: false, @@ -218,6 +371,7 @@ describe('FeishuRuntime', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: expandedText, + traceText: '', statusText: 'Running: writing...', finalText: '', completed: false, @@ -226,6 +380,7 @@ describe('FeishuRuntime', () => { .mockResolvedValue({ messageId: 'msg-1', text: expandedText, + traceText: '', statusText: 'Running: writing...', finalText: expandedText, completed: true, @@ -254,7 +409,7 @@ describe('FeishuRuntime', () => { await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) await vi.waitFor(() => { - expect(harness.client.updateText).toHaveBeenCalledWith('om_bot_2', 'A'.repeat(8_000)) + expect(harness.client.updateText).toHaveBeenCalledWith('om_bot_1', 'A'.repeat(8_000)) expect(harness.client.sendText).toHaveBeenCalledWith( { chatId: 'oc_1', @@ -268,7 +423,290 @@ describe('FeishuRuntime', () => { await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) await vi.waitFor(() => { - expect(harness.client.deleteMessage).toHaveBeenCalledWith('om_bot_1') + expect(harness.client.deleteMessage).not.toHaveBeenCalled() + }) + + await harness.runtime.stop() + } finally { + vi.useRealTimers() + } + }) + + it('preserves null messageId holes so later updates do not target the wrong chunk', async () => { + vi.useFakeTimers() + + try { + const harness = await createHarness() + const firstChunk = 'A'.repeat(FEISHU_OUTBOUND_TEXT_LIMIT) + const changedMiddleChunk = 'D'.repeat(FEISHU_OUTBOUND_TEXT_LIMIT) + const initialText = + firstChunk + 'B'.repeat(FEISHU_OUTBOUND_TEXT_LIMIT) + 'C'.repeat(FEISHU_OUTBOUND_TEXT_LIMIT) + const updatedText = firstChunk + changedMiddleChunk + 'C'.repeat(FEISHU_OUTBOUND_TEXT_LIMIT) + const sendResults: Array = ['om_bot_1', null, 'om_bot_3'] + + harness.client.sendText.mockImplementation(async () => + sendResults.length > 0 ? (sendResults.shift() as string | null) : 'om_bot_4' + ) + harness.router.handleMessage.mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi + .fn() + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: initialText, + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: initialText, + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValue({ + messageId: 'msg-1', + text: updatedText, + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: updatedText, + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: updatedText, + completed: true, + pendingInteraction: null + }) + } + }) + + await harness.onMessage({ + parsed: createParsedMessage({ + messageId: 'om_hole_alignment' + }) + }) + + await vi.waitFor(() => { + expect(harness.bindingStore.rememberRemoteDeliveryState).toHaveBeenCalled() + }) + + expect(harness.bindingStore.rememberRemoteDeliveryState.mock.calls[0]).toEqual([ + 'feishu:oc_1:root', + { + sourceMessageId: 'msg-1', + segments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + messageIds: ['om_bot_1', null, 'om_bot_3'], + lastText: initialText + } + ] + } + ]) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(harness.client.updateText).not.toHaveBeenCalledWith('om_bot_3', changedMiddleChunk) + }) + + await harness.runtime.stop() + } finally { + vi.useRealTimers() + } + }) + + it('appends later process and answer segments in DeepChat order instead of rewriting the first answer', async () => { + vi.useFakeTimers() + + try { + const harness = await createHarness() + harness.router.handleMessage.mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi + .fn() + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Let me inspect these files.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Let me inspect these files.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ“– read_file: "/tmp/report.md"', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: calling read_file...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValue({ + messageId: 'msg-1', + text: 'Summary ready.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ“– read_file: "/tmp/report.md"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:2:answer', + kind: 'answer', + text: 'Summary ready.', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: 'Summary ready.', + completed: true, + pendingInteraction: null + }) + } + }) + + await harness.onMessage({ + parsed: createParsedMessage({ + messageId: 'om_segment_order' + }) + }) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_segment_order' + }, + 'Let me inspect these files.' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_segment_order' + }, + 'šŸ“– read_file: "/tmp/report.md"' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_segment_order' + }, + 'Summary ready.' + ) + expect(harness.client.updateText).not.toHaveBeenCalledWith('om_bot_1', 'Summary ready.') + expect(harness.bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith( + 'feishu:oc_1:root' + ) + }) + + await harness.runtime.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps tool-only turns as trace-only without appending the no-response fallback', async () => { + vi.useFakeTimers() + + try { + const harness = await createHarness() + harness.router.handleMessage.mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn().mockResolvedValue({ + messageId: 'msg-1', + text: '', + traceText: 'šŸ“– read_file: "/tmp/report.md"', + statusText: 'Running: calling read_file...', + finalText: 'No assistant response was produced.', + renderBlocks: [], + completed: true, + pendingInteraction: null + }) + } + }) + + await harness.onMessage({ + parsed: createParsedMessage({ + messageId: 'om_trace_only' + }) + }) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalledTimes(1) + expect(harness.client.sendText).toHaveBeenCalledWith( + { + chatId: 'oc_1', + threadId: null, + replyToMessageId: 'om_trace_only' + }, + 'šŸ“– read_file: "/tmp/report.md"' + ) + expect(harness.bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith( + 'feishu:oc_1:root' + ) }) await harness.runtime.stop() @@ -844,14 +1282,6 @@ describe('FeishuRuntime', () => { }) await vi.waitFor(() => { - expect(harness.client.sendText).toHaveBeenCalledWith( - { - chatId: 'oc_1', - threadId: null, - replyToMessageId: 'om-pending-card' - }, - 'Waiting for your response...' - ) expect(harness.client.sendText).toHaveBeenCalledWith( { chatId: 'oc_1', diff --git a/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts index a492c0621..6506332fd 100644 --- a/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts @@ -272,18 +272,38 @@ describe('RemoteBindingStore', () => { store.rememberRemoteDeliveryState('telegram:100:0', { sourceMessageId: 'msg-1', - statusMessageId: 100, - contentMessageIds: [101], - lastStatusText: 'Running: writing...', - lastContentText: 'Draft answer' + segments: [ + { + key: 'msg-1:0:process', + kind: 'process', + messageIds: [100], + lastText: 'šŸ’» shell_command: "git status"' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + messageIds: [101], + lastText: 'Draft answer' + } + ] }) expect(store.getRemoteDeliveryState('telegram:100:0')).toEqual({ sourceMessageId: 'msg-1', - statusMessageId: 100, - contentMessageIds: [101], - lastStatusText: 'Running: writing...', - lastContentText: 'Draft answer' + segments: [ + { + key: 'msg-1:0:process', + kind: 'process', + messageIds: [100], + lastText: 'šŸ’» shell_command: "git status"' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + messageIds: [101], + lastText: 'Draft answer' + } + ] }) store.setBinding('telegram:100:0', 'session-2') diff --git a/test/main/presenter/remoteControlPresenter/remoteBlockRenderer.test.ts b/test/main/presenter/remoteControlPresenter/remoteBlockRenderer.test.ts index b5f088bc4..dd895dd17 100644 --- a/test/main/presenter/remoteControlPresenter/remoteBlockRenderer.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteBlockRenderer.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { + buildRemoteDeliverySegments, buildRemoteDraftText, buildRemoteFinalText, buildRemoteFullText, buildRemoteRenderableBlocks, buildRemoteStreamText, + buildRemoteTraceText, buildRemoteStatusText } from '@/presenter/remoteControlPresenter/services/remoteBlockRenderer' @@ -229,6 +231,189 @@ describe('remoteBlockRenderer', () => { expect(streamText).toBe('Visible answer\n\nMore answer') }) + it('builds remote trace lines from raw tool names and summarized params', () => { + const traceText = buildRemoteTraceText([ + { + type: 'tool_call', + content: '', + status: 'success', + timestamp: 1, + tool_call: { + id: 'tool-1', + name: 'search_files', + params: '{"query":"daily news|agent.dynamic|moti.send","limit":10}' + }, + extra: { + toolCallArgsComplete: true + } + }, + { + type: 'tool_call', + content: '', + status: 'success', + timestamp: 2, + tool_call: { + id: 'tool-2', + name: 'read_file', + params: '{"path":"/tmp/report.md"}' + }, + extra: { + toolCallArgsComplete: true + } + } + ]) + + expect(traceText).toBe( + 'šŸ”Ž search_files: "daily news|agent.dynamic|moti.send"\nšŸ“– read_file: "/tmp/report.md"' + ) + }) + + it('adds a separate error trace line for failed tool calls and truncates long previews', () => { + const traceText = buildRemoteTraceText([ + { + type: 'tool_call', + content: '', + status: 'error', + timestamp: 1, + tool_call: { + id: 'tool-1', + name: 'shell_command', + params: JSON.stringify({ + command: `ls ${'very-long-segment/'.repeat(20)}` + }), + response: JSON.stringify({ + error: 'permission denied' + }) + }, + extra: { + toolCallArgsComplete: true + } + } + ]) + + expect(traceText).toContain('šŸ’» shell_command: "ls very-long-segment/') + expect(traceText).toContain('..."') + expect(traceText).toContain('\nāŒ shell_command: "permission denied"') + }) + + it('does not build trace text for answer-only turns', () => { + const traceText = buildRemoteTraceText([ + { + type: 'content', + content: 'Only the final answer', + status: 'success', + timestamp: 1 + } + ]) + + expect(traceText).toBe('') + }) + + it('builds ordered delivery segments that preserve answer and process transitions', () => { + const segments = buildRemoteDeliverySegments('msg-1', [ + { + type: 'content', + content: 'Reviewing these files first.', + status: 'success', + timestamp: 1 + }, + { + type: 'tool_call', + content: '', + status: 'success', + timestamp: 2, + tool_call: { + id: 'tool-1', + name: 'read_file', + params: '{"path":"/tmp/a.md"}' + }, + extra: { + toolCallArgsComplete: true + } + }, + { + type: 'tool_call', + content: '', + status: 'error', + timestamp: 3, + tool_call: { + id: 'tool-2', + name: 'shell_command', + params: '{"command":"ls -la"}', + response: '{"error":"permission denied"}' + }, + extra: { + toolCallArgsComplete: true + } + }, + { + type: 'content', + content: 'Everything is organized.', + status: 'pending', + timestamp: 4 + } + ]) + + expect(segments).toEqual([ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Reviewing these files first.', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ“– read_file: "/tmp/a.md"\nšŸ’» shell_command: "ls -la"\nāŒ shell_command: "permission denied"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:3:answer', + kind: 'answer', + text: 'Everything is organized.', + sourceMessageId: 'msg-1' + } + ]) + }) + + it('keeps consecutive answer blocks together when hidden blocks appear between them', () => { + const segments = buildRemoteDeliverySegments('msg-2', [ + { + type: 'content', + content: 'Part 1', + status: 'success', + timestamp: 1 + }, + { + type: 'reasoning_content', + content: 'hidden', + status: 'success', + timestamp: 2 + }, + { + type: 'search', + content: '', + status: 'success', + timestamp: 3 + }, + { + type: 'content', + content: 'Part 2', + status: 'success', + timestamp: 4 + } + ]) + + expect(segments).toEqual([ + { + key: 'msg-2:0:answer', + kind: 'answer', + text: 'Part 1\n\nPart 2', + sourceMessageId: 'msg-2' + } + ]) + }) + it('builds compact status text for tool execution and waiting states', () => { expect( buildRemoteStatusText([ diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index bcfb248e9..0da2e5d45 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -394,6 +394,8 @@ describe('RemoteConversationRunner', () => { expect(snapshot).toEqual({ messageId: null, text: 'No assistant response was produced.', + traceText: '', + deliverySegments: [], statusText: '', finalText: 'No assistant response was produced.', draftText: '', @@ -669,6 +671,15 @@ describe('RemoteConversationRunner', () => { expect(snapshot).toEqual({ messageId: 'assistant-2', text: 'Push completed.', + traceText: '', + deliverySegments: [ + { + key: 'assistant-2:0:answer', + kind: 'answer', + text: 'Push completed.', + sourceMessageId: 'assistant-2' + } + ], statusText: 'Running: writing...', finalText: 'Push completed.', draftText: '', diff --git a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts index 2a59def85..d87860628 100644 --- a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts +++ b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { TelegramApiRequestError } from '@/presenter/remoteControlPresenter/telegram/telegramClient' import { TelegramPoller } from '@/presenter/remoteControlPresenter/telegram/telegramPoller' -import { TELEGRAM_STREAM_POLL_INTERVAL_MS } from '@/presenter/remoteControlPresenter/types' +import { + TELEGRAM_OUTBOUND_TEXT_LIMIT, + TELEGRAM_STREAM_POLL_INTERVAL_MS +} from '@/presenter/remoteControlPresenter/types' const createClient = () => { let nextMessageId = 100 @@ -36,7 +39,10 @@ const createBindingStore = () => { rememberRemoteDeliveryState: vi.fn((endpointKey: string, state: any) => { deliveryStates.set(endpointKey, { ...state, - contentMessageIds: [...state.contentMessageIds] + segments: state.segments.map((segment: any) => ({ + ...segment, + messageIds: [...segment.messageIds] + })) }) }), clearRemoteDeliveryState: vi.fn((endpointKey: string) => { @@ -387,7 +393,7 @@ describe('TelegramPoller', () => { await poller.stop() }) - it('streams answer text beside a temporary status message', async () => { + it('streams answer text beside a persistent trace log', async () => { vi.useFakeTimers() try { @@ -438,6 +444,15 @@ describe('TelegramPoller', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: '', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + } + ], statusText: 'Running: thinking...', finalText: '', draftText: '', @@ -449,6 +464,21 @@ describe('TelegramPoller', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: 'Draft answer', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + text: 'Draft answer', + sourceMessageId: 'msg-1' + } + ], statusText: 'Running: writing...', finalText: '', draftText: '', @@ -460,6 +490,21 @@ describe('TelegramPoller', () => { .mockResolvedValue({ messageId: 'msg-1', text: 'Draft answer', + traceText: 'šŸ’» shell_command: "git status"', + deliverySegments: [ + { + key: 'msg-1:0:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + text: 'Final answer', + sourceMessageId: 'msg-1' + } + ], statusText: 'Running: writing...', finalText: 'Final answer', draftText: '', @@ -482,7 +527,7 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Running: thinking...' + 'šŸ’» shell_command: "git status"' ) }) @@ -496,23 +541,24 @@ describe('TelegramPoller', () => { }, 'Draft answer' ) - expect(client.editMessageText).toHaveBeenCalledWith({ - target: { - chatId: 100, - messageThreadId: 0 - }, - messageId: 100, - text: 'Running: writing...', - replyMarkup: undefined - }) expect(bindingStore.rememberRemoteDeliveryState).toHaveBeenCalledWith( 'telegram:100:0', expect.objectContaining({ sourceMessageId: 'msg-1', - statusMessageId: 100, - contentMessageIds: [101], - lastStatusText: 'Running: writing...', - lastContentText: 'Draft answer' + segments: [ + { + key: 'msg-1:0:process', + kind: 'process', + messageIds: [100], + lastText: 'šŸ’» shell_command: "git status"' + }, + { + key: 'msg-1:1:answer', + kind: 'answer', + messageIds: [101], + lastText: 'Draft answer' + } + ] }) ) }) @@ -520,6 +566,11 @@ describe('TelegramPoller', () => { await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) await vi.waitFor(() => { + expect(client.editMessageText).not.toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 100 + }) + ) expect(client.editMessageText).toHaveBeenCalledWith({ target: { chatId: 100, @@ -529,13 +580,6 @@ describe('TelegramPoller', () => { text: 'Final answer', replyMarkup: undefined }) - expect(client.deleteMessage).toHaveBeenCalledWith({ - target: { - chatId: 100, - messageThreadId: 0 - }, - messageId: 100 - }) expect(bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith('telegram:100:0') }) @@ -599,6 +643,7 @@ describe('TelegramPoller', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: firstText, + traceText: '', statusText: 'Running: writing...', finalText: '', completed: false, @@ -607,6 +652,7 @@ describe('TelegramPoller', () => { .mockResolvedValueOnce({ messageId: 'msg-1', text: expandedText, + traceText: '', statusText: 'Running: writing...', finalText: '', completed: false, @@ -615,6 +661,7 @@ describe('TelegramPoller', () => { .mockResolvedValue({ messageId: 'msg-1', text: expandedText, + traceText: '', statusText: 'Running: writing...', finalText: expandedText, completed: true, @@ -646,7 +693,7 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - messageId: 101, + messageId: 100, text: 'A'.repeat(4_096), replyMarkup: undefined }) @@ -662,13 +709,610 @@ describe('TelegramPoller', () => { await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) await vi.waitFor(() => { - expect(client.deleteMessage).toHaveBeenCalledWith({ - target: { + expect(client.deleteMessage).not.toHaveBeenCalled() + }) + + await poller.stop() + } finally { + vi.useRealTimers() + } + }) + + it('preserves null messageId holes from stored delivery state so edits stay aligned', async () => { + vi.useFakeTimers() + + try { + const client = createClient() + const bindingStore = createBindingStore() + const firstChunk = 'A'.repeat(TELEGRAM_OUTBOUND_TEXT_LIMIT) + const changedMiddleChunk = 'D'.repeat(TELEGRAM_OUTBOUND_TEXT_LIMIT) + const initialText = + firstChunk + + ' ' + + 'B'.repeat(TELEGRAM_OUTBOUND_TEXT_LIMIT) + + ' ' + + 'C'.repeat(TELEGRAM_OUTBOUND_TEXT_LIMIT) + const updatedText = + firstChunk + ' ' + changedMiddleChunk + ' ' + 'C'.repeat(TELEGRAM_OUTBOUND_TEXT_LIMIT) + + bindingStore.rememberRemoteDeliveryState('telegram:100:0', { + sourceMessageId: 'msg-1', + segments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + messageIds: [100, null, 102], + lastText: initialText + } + ] + }) + + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn().mockResolvedValue({ + messageId: 'msg-1', + text: updatedText, + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: updatedText, + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: updatedText, + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.editMessageText).not.toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 102, + text: changedMiddleChunk + }) + ) + }) + + await poller.stop() + } finally { + vi.useRealTimers() + } + }) + + it('appends terminal text after a partial answer when the final state differs', async () => { + vi.useFakeTimers() + + try { + const client = createClient() + const bindingStore = createBindingStore() + + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi + .fn() + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Partial answer', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Partial answer', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValue({ + messageId: 'msg-1', + text: 'Partial answer', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Partial answer', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: 'The conversation ended with an error.', + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { chatId: 100, messageThreadId: 0 }, - messageId: 100 - }) + 'Partial answer' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'The conversation ended with an error.' + ) + expect(client.editMessageText).not.toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 100, + text: 'The conversation ended with an error.' + }) + ) + }) + + await poller.stop() + } finally { + vi.useRealTimers() + } + }) + + it('does not append a terminal segment when the latest answer already matches after a process segment', async () => { + vi.useFakeTimers() + + try { + const client = createClient() + const bindingStore = createBindingStore() + + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn().mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Final answer', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Final answer', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ’» shell_command: "git status"', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: processing tool results...', + finalText: 'Final answer', + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'Final answer' + ) + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'šŸ’» shell_command: "git status"' + ) + }) + + expect(client.sendMessage).not.toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'Final answer', + expect.anything() + ) + expect( + client.sendMessage.mock.calls.filter(([, text]) => text === 'Final answer') + ).toHaveLength(1) + + await poller.stop() + } finally { + vi.useRealTimers() + } + }) + + it('appends later process and answer segments in DeepChat order instead of rewriting the first answer', async () => { + vi.useFakeTimers() + + try { + const client = createClient() + const bindingStore = createBindingStore() + + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi + .fn() + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Let me inspect these files.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValueOnce({ + messageId: 'msg-1', + text: 'Let me inspect these files.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ“– read_file: "/tmp/report.md"', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: calling read_file...', + finalText: '', + completed: false, + pendingInteraction: null + }) + .mockResolvedValue({ + messageId: 'msg-1', + text: 'Summary ready.', + traceText: '', + deliverySegments: [ + { + key: 'msg-1:0:answer', + kind: 'answer', + text: 'Let me inspect these files.', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:1:process', + kind: 'process', + text: 'šŸ“– read_file: "/tmp/report.md"', + sourceMessageId: 'msg-1' + }, + { + key: 'msg-1:2:answer', + kind: 'answer', + text: 'Summary ready.', + sourceMessageId: 'msg-1' + } + ], + statusText: 'Running: writing...', + finalText: 'Summary ready.', + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'Let me inspect these files.' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'šŸ“– read_file: "/tmp/report.md"' + ) + }) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'Summary ready.' + ) + expect(client.editMessageText).not.toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 100, + text: 'Summary ready.' + }) + ) + expect(bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith('telegram:100:0') + }) + + await poller.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps tool-only turns as trace-only without appending the no-response fallback', async () => { + vi.useFakeTimers() + + try { + const client = createClient() + const bindingStore = createBindingStore() + + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn().mockResolvedValue({ + messageId: 'msg-1', + text: '', + traceText: 'šŸ“– read_file: "/tmp/report.md"', + statusText: 'Running: calling read_file...', + finalText: 'No assistant response was produced.', + renderBlocks: [], + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledTimes(1) + expect(client.sendMessage).toHaveBeenCalledWith( + { + chatId: 100, + messageThreadId: 0 + }, + 'šŸ“– read_file: "/tmp/report.md"' + ) + expect(bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith('telegram:100:0') }) await poller.stop() @@ -1132,18 +1776,10 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Waiting for your response...' - ) - expect(client.sendMessage).toHaveBeenNthCalledWith( - 2, - { - chatId: 100, - messageThreadId: 0 - }, 'Partial answer' ) expect(client.sendMessage).toHaveBeenNthCalledWith( - 3, + 2, { chatId: 100, messageThreadId: 0