feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
Cheng Zi Feng
2026-04-17 16:21:27 +08:00
committed by GitHub
parent b5c299f5d2
commit 72a2093cd6
64 changed files with 4138 additions and 312 deletions

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import {
shouldReportRunningForMessage,
shouldReportRunningForMessages,
} from '../bridgeMessaging.js'
import { createUserMessage } from '../../utils/messages.js'
describe('bridge running-state classification', () => {
test('treats real user prompts as turn-starting work', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({ content: 'please inspect the repo' }),
),
).toBe(true)
})
test('keeps tool-result style user messages eligible during mid-turn attach', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content: '<local-command-stdout>done</local-command-stdout>',
toolUseResult: { ok: true },
}),
),
).toBe(true)
})
test('ignores local slash-command scaffolding that should not reopen a turn', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content:
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
isMeta: true,
}),
),
).toBe(false)
expect(
shouldReportRunningForMessage(
createUserMessage({
content:
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>',
isMeta: true,
}),
),
).toBe(false)
})
test('still marks real automation triggers as running', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content: '<tick>2:56:47 PM</tick>',
isMeta: true,
}),
),
).toBe(true)
expect(
shouldReportRunningForMessage(
createUserMessage({
content: 'scheduled job: refresh analytics cache',
isMeta: true,
}),
),
).toBe(true)
})
test('classifies batches by any work-starting message', () => {
const scaffoldingOnly = [
createUserMessage({
content:
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
isMeta: true,
}),
createUserMessage({
content:
'<system-reminder>\nProactive mode is now enabled.\n</system-reminder>',
isMeta: true,
}),
]
expect(shouldReportRunningForMessages(scaffoldingOnly)).toBe(false)
expect(
shouldReportRunningForMessages([
...scaffoldingOnly,
createUserMessage({
content: '<tick>2:57:17 PM</tick>',
isMeta: true,
}),
]),
).toBe(true)
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from 'bun:test'
import { parseBridgePermissionResponse } from '../bridgePermissionCallbacks.js'
import type { SDKControlResponse } from '../../entrypoints/sdk/controlTypes.js'
describe('parseBridgePermissionResponse', () => {
test('passes through allow responses', () => {
const message: SDKControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: {
behavior: 'allow',
updatedPermissions: [
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
],
},
},
}
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'allow',
updatedPermissions: [
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
],
})
})
test('maps error responses with feedback to deny', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-2',
error: 'Permission denied by user',
response: { behavior: 'deny' },
message: 'Need more detail',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'deny',
message: 'Need more detail',
})
})
test('falls back to error text when deny feedback is absent', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-3',
error: 'Permission denied by user',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'deny',
message: 'Permission denied by user',
})
})
test('returns null for unrelated control responses', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-4',
error: '',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toBeNull()
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import {
hasPendingBridgeMessages,
isTranscriptResetResultReady,
shouldDeferBridgeResult,
} from '../bridgeResultScheduling.js'
describe('bridgeResultScheduling', () => {
test('detects pending mirrored messages', () => {
expect(hasPendingBridgeMessages(2, 3)).toBe(true)
expect(hasPendingBridgeMessages(3, 3)).toBe(false)
})
test('defers when the bridge handle is unavailable', () => {
expect(
shouldDeferBridgeResult({
hasHandle: false,
isConnected: true,
lastWrittenIndex: 3,
messageCount: 3,
}),
).toBe(true)
})
test('defers when the bridge is connected but transcript flush is pending', () => {
expect(
shouldDeferBridgeResult({
hasHandle: true,
isConnected: true,
lastWrittenIndex: 1,
messageCount: 2,
}),
).toBe(true)
})
test('sends immediately once the latest transcript is already mirrored', () => {
expect(
shouldDeferBridgeResult({
hasHandle: true,
isConnected: true,
lastWrittenIndex: 2,
messageCount: 2,
}),
).toBe(false)
})
test('treats transcript reset as ready only after the transcript is empty', () => {
expect(isTranscriptResetResultReady(true, 0)).toBe(true)
expect(isTranscriptResetResultReady(true, 1)).toBe(false)
expect(isTranscriptResetResultReady(false, 0)).toBe(false)
})
})

View File

@@ -0,0 +1,37 @@
import { feature } from 'bun:bundle'
import { afterEach, describe, expect, test } from 'bun:test'
import { handleRemoteInterrupt } from '../remoteInterruptHandling.js'
import {
activateProactive,
deactivateProactive,
isProactivePaused,
} from '../../proactive/index.js'
function isProactiveFeatureEnabled() {
if (feature('PROACTIVE')) return true
return feature('KAIROS') ? true : false
}
describe('handleRemoteInterrupt', () => {
afterEach(() => {
deactivateProactive()
})
test('always aborts the active request', () => {
const controller = new AbortController()
handleRemoteInterrupt(controller)
expect(controller.signal.aborted).toBe(true)
})
test('pauses proactive mode to return control to the user', () => {
activateProactive('test')
expect(isProactivePaused()).toBe(false)
handleRemoteInterrupt(new AbortController())
expect(isProactivePaused()).toBe(isProactiveFeatureEnabled())
})
})

View File

@@ -28,6 +28,18 @@ import { errorMessage } from '../utils/errors.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { jsonParse } from '../utils/slowOperations.js'
import type { ReplBridgeTransport } from './replBridgeTransport.js'
import {
BASH_INPUT_TAG,
CHANNEL_MESSAGE_TAG,
CROSS_SESSION_MESSAGE_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
REMOTE_REVIEW_PROGRESS_TAG,
REMOTE_REVIEW_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
ULTRAPLAN_TAG,
} from '../constants/xml.js'
// ─── Type guards ─────────────────────────────────────────────────────────────
@@ -122,6 +134,85 @@ export function extractTitleText(m: Message): string | undefined {
return clean || undefined
}
const SYSTEM_REMINDER_TAG = 'system-reminder'
const XML_BLOCK_PATTERN =
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
const RUNNING_STATE_META_TAGS = new Set([
BASH_INPUT_TAG,
CHANNEL_MESSAGE_TAG,
CROSS_SESSION_MESSAGE_TAG,
REMOTE_REVIEW_PROGRESS_TAG,
REMOTE_REVIEW_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
ULTRAPLAN_TAG,
])
function extractUserMessageText(message: Message): string {
const content = message.message?.content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.filter(
(
block,
): block is {
type: 'text'
text: string
} =>
!!block &&
typeof block === 'object' &&
block.type === 'text' &&
typeof block.text === 'string',
)
.map(block => block.text)
.join('')
}
function getEnvelopeTagNames(text: string): string[] | null {
const trimmed = text.trim()
if (!trimmed) return null
XML_BLOCK_PATTERN.lastIndex = 0
const tags: string[] = []
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
const match = XML_BLOCK_PATTERN.exec(trimmed)
if (!match) return null
tags.push(match[1]!)
}
return tags.length > 0 ? tags : null
}
/**
* Remote Control uses user messages to infer "a turn is actively running" in
* places where the server does not derive that state for us. Hidden local
* slash-command scaffolding (for example `<local-command-caveat>` and pure
* `<system-reminder>` wrappers from `/proactive`) should not flip the session
* back to running after the command has already completed.
*/
export function shouldReportRunningForMessage(message: Message): boolean {
if (message.type !== 'user') return false
if (message.isVisibleInTranscriptOnly) return false
if (message.toolUseResult !== undefined) return true
if (!message.isMeta) return true
const tags = getEnvelopeTagNames(extractUserMessageText(message))
if (!tags) return true
return tags.some(
tag =>
tag !== LOCAL_COMMAND_CAVEAT_TAG &&
tag !== SYSTEM_REMINDER_TAG &&
RUNNING_STATE_META_TAGS.has(tag),
)
}
export function shouldReportRunningForMessages(
messages: readonly Message[],
): boolean {
return messages.some(shouldReportRunningForMessage)
}
// ─── Ingress routing ─────────────────────────────────────────────────────────
/**

View File

@@ -1,4 +1,5 @@
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
type BridgePermissionResponse = {
behavior: 'allow' | 'deny'
@@ -39,5 +40,65 @@ function isBridgePermissionResponse(
)
}
export { isBridgePermissionResponse }
function toBridgePermissionMessage(
controlResponse: Record<string, unknown>,
parsed: BridgePermissionResponse | undefined,
): string | undefined {
if (typeof controlResponse.message === 'string' && controlResponse.message) {
return controlResponse.message
}
if (typeof parsed?.message === 'string' && parsed.message) {
return parsed.message
}
if (typeof controlResponse.error === 'string' && controlResponse.error) {
return controlResponse.error
}
return undefined
}
/**
* Normalize a control_response from the bridge transport into the simplified
* allow/deny shape used by interactive permission handlers.
*/
function parseBridgePermissionResponse(
message: SDKControlResponse,
): BridgePermissionResponse | null {
const controlResponse = message.response
if (!controlResponse || typeof controlResponse !== 'object') return null
if (
controlResponse.subtype === 'success' &&
'response' in controlResponse &&
isBridgePermissionResponse(controlResponse.response)
) {
return controlResponse.response
}
if (controlResponse.subtype !== 'error') {
return null
}
const nested =
'response' in controlResponse &&
isBridgePermissionResponse(controlResponse.response)
? controlResponse.response
: undefined
const messageText = toBridgePermissionMessage(controlResponse, nested)
if (nested) {
return messageText ? { ...nested, message: messageText } : nested
}
if (messageText) {
return {
behavior: 'deny',
message: messageText,
}
}
return null
}
export { isBridgePermissionResponse, parseBridgePermissionResponse }
export type { BridgePermissionCallbacks, BridgePermissionResponse }

View File

@@ -0,0 +1,28 @@
export function hasPendingBridgeMessages(
lastWrittenIndex: number,
messageCount: number,
): boolean {
return lastWrittenIndex < messageCount
}
export function isTranscriptResetResultReady(
transcriptResetPending: boolean,
messageCount: number,
): boolean {
return transcriptResetPending && messageCount === 0
}
export function shouldDeferBridgeResult({
hasHandle,
isConnected,
lastWrittenIndex,
messageCount,
}: {
hasHandle: boolean
isConnected: boolean
lastWrittenIndex: number
messageCount: number
}): boolean {
if (!hasHandle || !isConnected) return true
return hasPendingBridgeMessages(lastWrittenIndex, messageCount)
}

View File

@@ -49,6 +49,8 @@ import {
makeResultMessage,
isEligibleBridgeMessage,
extractTitleText,
shouldReportRunningForMessage,
shouldReportRunningForMessages,
BoundedUUIDSet,
} from './bridgeMessaging.js'
import { logBridgeSkip } from './debugUtils.js'
@@ -72,6 +74,7 @@ import type {
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
/**
* StdoutMessage with optional session_id. The transport layer accepts
@@ -321,6 +324,18 @@ export async function initEnvLessBridgeCore(
})
}
// Mirror external metadata updates from the live REPL into the bridge's
// CCR worker channel. Without this, proactive wait/sleep only changes local
// UI state and the web session detail falls back to the generic working
// spinner because automation_state never reaches remote-control.
setSessionMetadataChangedListener(
metadata => {
if (tornDown) return
transport.reportMetadata(metadata)
},
{ replayCurrent: true },
)
// ── 5. JWT refresh scheduler ────────────────────────────────────────────
// Schedule a callback 5min before expiry (per response.expires_in). On fire,
// re-fetch /bridge with OAuth → rebuild transport with fresh credentials.
@@ -625,7 +640,7 @@ export async function initEnvLessBridgeCore(
...m,
session_id: sessionId,
})) as TransportMessage[]
if (msgs.some(m => m.type === 'user')) {
if (shouldReportRunningForMessages(msgs)) {
transport.reportState('running')
}
logForDebugging(
@@ -655,13 +670,13 @@ export async function initEnvLessBridgeCore(
})) as TransportMessage[]
if (events.length === 0) return
// Mid-turn init: if Remote Control is enabled while a query is running,
// the last eligible message is a user prompt or tool_result (both 'user'
// type). Without this the init PUT's 'idle' sticks until the next user-
// type message forwards via writeMessages — which for a pure-text turn
// is never (only assistant chunks stream post-init). Check eligible (pre-
// cap), not capped: the cap may truncate to a user message even when the
// actual trailing message is assistant.
if (eligible.at(-1)?.type === 'user') {
// the last eligible message may be a real user prompt or tool_result.
// Hidden slash-command scaffolding and pure reminder wrappers should not
// resurrect a completed turn into "running". Check eligible (pre-cap),
// not capped: the cap may truncate to a user message even when the actual
// trailing message is assistant.
const lastEligible = eligible.at(-1)
if (lastEligible && shouldReportRunningForMessage(lastEligible)) {
transport.reportState('running')
}
logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
@@ -817,10 +832,11 @@ export async function initEnvLessBridgeCore(
})) as TransportMessage[]
// v2 does not derive worker_status from events server-side (unlike v1
// session-ingress session_status_updater.go). Push it from here so the
// CCR web session list shows Running instead of stuck on Idle. A user
// message in the batch marks turn start. CCRClient.reportState dedupes
// consecutive same-state pushes.
if (filtered.some(m => m.type === 'user')) {
// CCR web session list shows Running instead of stuck on Idle. Only
// work-starting user messages mark turn start; hidden local-command
// scaffolding and pure reminders should not re-open a completed turn.
// CCRClient.reportState dedupes consecutive same-state pushes.
if (shouldReportRunningForMessages(filtered)) {
transport.reportState('running')
}
logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)

View File

@@ -0,0 +1,13 @@
import { feature } from 'bun:bundle'
export function handleRemoteInterrupt(
abortController: AbortController | null,
): void {
if (feature('PROACTIVE') || feature('KAIROS')) {
const { pauseProactive } =
require('../proactive/index.js') as typeof import('../proactive/index.js')
pauseProactive()
}
abortController?.abort()
}

View File

@@ -39,6 +39,7 @@ import {
createV2ReplTransport,
} from './replBridgeTransport.js'
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
import { validateBridgeId } from './bridgeApi.js'
import {
@@ -87,6 +88,7 @@ export type ReplBridgeHandle = {
sessionIngressUrl: string
writeMessages(messages: Message[]): void
writeSdkMessages(messages: SDKMessage[]): void
markTranscriptReset?(): void
sendControlRequest(request: SDKControlRequest): void
sendControlResponse(response: SDKControlResponse): void
sendControlCancelRequest(requestId: string): void
@@ -555,6 +557,17 @@ export async function initBridgeCore(
// server-driven via secret.use_code_sessions, with CLAUDE_BRIDGE_USE_CCR_V2
// as an ant-dev override.
let transport: ReplBridgeTransport | null = null
// Mirror external metadata updates from the active REPL into whichever
// transport currently owns the remote-control session. v1 ignores the call;
// v2 forwards it to CCR /worker external_metadata so standby/sleeping and
// other session metadata survive on web/mobile.
setSessionMetadataChangedListener(
metadata => {
if (pollController.signal.aborted) return
transport?.reportMetadata(metadata)
},
{ replayCurrent: true },
)
// Bumped on every onWorkReceived. Captured in createV2ReplTransport's .then()
// closure to detect stale resolutions: if two calls race while transport is
// null, both registerWorker() (bumping server epoch), and whichever resolves
@@ -1869,6 +1882,7 @@ export async function initBridgeCore(
)
return
}
transport.reportState('idle')
const resultMsg = {
...makeResultMessage(currentSessionId),
session_id: currentSessionId,