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,30 @@
import { describe, expect, test } from 'bun:test'
import { isSlashCommand } from '../messageQueueManager.js'
describe('messageQueueManager.isSlashCommand', () => {
test('treats normal slash commands as slash commands', () => {
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
})
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
expect(
isSlashCommand({
value: '/proactive',
mode: 'prompt',
skipSlashCommands: true,
bridgeOrigin: true,
} as any),
).toBe(true)
})
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
expect(
isSlashCommand({
value: '/proactive',
mode: 'prompt',
skipSlashCommands: true,
} as any),
).toBe(false)
})
})

View File

@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
notifyAutomationStateChanged,
notifySessionStateChanged,
notifySessionMetadataChanged,
resetSessionStateForTests,
setSessionMetadataChangedListener,
} from '../sessionState'
describe('sessionState metadata replay', () => {
beforeEach(() => {
resetSessionStateForTests()
})
test('replays cached automation state to listeners that attach later', () => {
const seen: Array<Record<string, unknown>> = []
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
},
},
])
})
test('dedupes identical automation states after replay but forwards changes', () => {
const seen: Array<Record<string, unknown>> = []
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
})
expect(seen).toEqual([
{
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
},
},
{
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
},
},
])
})
test('replays merged metadata snapshots instead of only the latest delta', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionMetadataChanged({ model: 'claude-sonnet-4-6' })
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
model: 'claude-sonnet-4-6',
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
},
},
])
})
test('replays pending_action metadata cached through session-state transitions', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionStateChanged('requires_action', {
tool_name: 'Edit',
action_description: 'Edit src/utils/sessionState.ts',
tool_use_id: 'toolu_123',
request_id: 'req_123',
input: { path: 'src/utils/sessionState.ts' },
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
pending_action: {
tool_name: 'Edit',
action_description: 'Edit src/utils/sessionState.ts',
tool_use_id: 'toolu_123',
request_id: 'req_123',
input: { path: 'src/utils/sessionState.ts' },
},
},
])
})
test('replays cleared task_summary metadata after returning to idle', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionMetadataChanged({ task_summary: 'Running regression suite' })
notifySessionStateChanged('idle')
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
task_summary: null,
},
])
})
})

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import {
buildTaskStateMessage,
getTaskStateSnapshotKey,
} from "../taskStateMessage";
describe("buildTaskStateMessage", () => {
test("filters internal tasks and preserves public task fields", () => {
const message = buildTaskStateMessage("tasklist", [
{
id: "1",
subject: "Visible task",
description: "Shown in web UI",
activeForm: "Doing visible task",
status: "in_progress",
owner: "agent-1",
blocks: ["2"],
blockedBy: [],
},
{
id: "2",
subject: "Internal task",
description: "Hidden from web UI",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { _internal: true },
},
]);
expect(message.type).toBe("task_state");
expect(message.task_list_id).toBe("tasklist");
expect(message.uuid).toEqual(expect.any(String));
expect(message.tasks).toEqual([
{
id: "1",
subject: "Visible task",
description: "Shown in web UI",
activeForm: "Doing visible task",
status: "in_progress",
owner: "agent-1",
blocks: ["2"],
blockedBy: [],
},
]);
});
test("builds a stable snapshot key for equivalent public tasks", () => {
const tasks = [
{
id: "2",
subject: "Second",
description: "Second task",
status: "pending",
blocks: [],
blockedBy: [],
},
{
id: "1",
subject: "First",
description: "First task",
status: "in_progress",
blocks: ["2"],
blockedBy: [],
},
{
id: "internal",
subject: "Internal task",
description: "Hidden",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { _internal: true },
},
];
const firstKey = getTaskStateSnapshotKey("tasklist", tasks as any);
const secondKey = getTaskStateSnapshotKey("tasklist", [...tasks].reverse() as any);
const message = buildTaskStateMessage("tasklist", tasks as any);
expect(firstKey).toBe(secondKey);
expect(message.tasks.map(task => task.id)).toEqual(["1", "2"]);
});
});

View File

@@ -121,6 +121,8 @@ export type HandlePromptSubmitParams = BaseExecutionParams & {
* trigger local slash commands or skills.
*/
skipSlashCommands?: boolean
/** Preserves that the input originated from Remote Control when queued. */
bridgeOrigin?: boolean
}
export async function handlePromptSubmit(
@@ -147,6 +149,7 @@ export async function handlePromptSubmit(
queuedCommands,
uuid,
skipSlashCommands,
bridgeOrigin,
} = params
const { setCursorOffset, clearBuffer, resetHistory } = helpers
@@ -345,6 +348,7 @@ export async function handlePromptSubmit(
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
})
@@ -368,6 +372,7 @@ export async function handlePromptSubmit(
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
}

View File

@@ -535,13 +535,14 @@ export function getCommandsByMaxPriority(
* Returns true if the command is a slash command that should be routed through
* processSlashCommand rather than sent to the model as text.
*
* Commands with `skipSlashCommands` (e.g. bridge/CCR messages) are NOT treated
* as slash commands — their text is meant for the model.
* Commands with `skipSlashCommands` are usually treated as plain text, except
* Remote Control bridge messages (`bridgeOrigin`) that are re-validated later
* through isBridgeSafeCommand().
*/
export function isSlashCommand(cmd: QueuedCommand): boolean {
return (
typeof cmd.value === 'string' &&
cmd.value.trim().startsWith('/') &&
!cmd.skipSlashCommands
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
)
}

View File

@@ -10,8 +10,8 @@ import { logEvent } from 'src/services/analytics/index.js'
import { getContentText } from 'src/utils/messages.js'
import {
findCommand,
getBridgeCommandSafety,
getCommandName,
isBridgeSafeCommand,
type LocalJSXCommandContext,
} from '../../commands.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
@@ -432,10 +432,13 @@ async function processUserInputBase(
? findCommand(parsed.commandName, context.options.commands)
: undefined
if (cmd) {
if (isBridgeSafeCommand(cmd)) {
const safety = getBridgeCommandSafety(cmd, parsed?.args ?? '')
if (safety.ok) {
effectiveSkipSlash = false
} else {
const msg = `/${getCommandName(cmd)} isn't available over Remote Control.`
const msg =
safety.reason ??
`/${getCommandName(cmd)} isn't available over Remote Control.`
return {
messages: [
createUserMessage({ content: inputString, uuid }),

View File

@@ -19,12 +19,15 @@ type ProcessQueueResult = {
*/
function isSlashCommand(cmd: QueuedCommand): boolean {
if (typeof cmd.value === 'string') {
return cmd.value.trim().startsWith('/')
return cmd.value.trim().startsWith('/') && (!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
}
// For ContentBlockParam[], check the first text block
for (const block of cmd.value) {
if (block.type === 'text') {
return block.text.trim().startsWith('/')
return (
block.text.trim().startsWith('/') &&
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
)
}
}
return false

View File

@@ -1,5 +1,7 @@
export type SessionState = 'idle' | 'running' | 'requires_action'
import { isProactiveActive } from '../proactive/index.js'
/**
* Context carried with requires_action transitions so downstream
* surfaces (CCR sidebar, push notifications) can show what the
@@ -23,6 +25,15 @@ export type RequiresActionDetails = {
input?: Record<string, unknown>
}
export type AutomationStatePhase = 'standby' | 'sleeping'
export type AutomationStateMetadata = {
enabled: boolean
phase: AutomationStatePhase | null
next_tick_at: number | null
sleep_until: number | null
}
import { isEnvTruthy } from './envUtils.js'
import type { PermissionMode } from './permissions/PermissionMode.js'
import { enqueueSdkEvent } from './sdkEventQueue.js'
@@ -34,6 +45,7 @@ export type SessionExternalMetadata = {
is_ultraplan_mode?: boolean | null
model?: string | null
pending_action?: RequiresActionDetails | null
automation_state?: AutomationStateMetadata | null
// Opaque — typed at the emit site. Importing PostTurnSummaryOutput here
// would leak the import path string into sdk.d.ts via agentSdkBridge's
// re-export of SessionState.
@@ -52,6 +64,9 @@ type SessionMetadataChangedListener = (
metadata: SessionExternalMetadata,
) => void
type PermissionModeChangedListener = (mode: PermissionMode) => void
type SessionMetadataListenerOptions = {
replayCurrent?: boolean
}
let stateListener: SessionStateChangedListener | null = null
let metadataListener: SessionMetadataChangedListener | null = null
@@ -65,8 +80,19 @@ export function setSessionStateChangedListener(
export function setSessionMetadataChangedListener(
cb: SessionMetadataChangedListener | null,
options?: SessionMetadataListenerOptions,
): void {
metadataListener = cb
if (!cb || !options?.replayCurrent) {
return
}
const snapshot = getSessionMetadataSnapshot()
if (Object.keys(snapshot).length === 0) {
return
}
cb(snapshot)
}
/**
@@ -84,6 +110,61 @@ export function setPermissionModeChangedListener(
let hasPendingAction = false
let currentState: SessionState = 'idle'
let currentAutomationState: AutomationStateMetadata | null = null
let currentMetadata: SessionExternalMetadata = {}
function normalizeAutomationState(
state: AutomationStateMetadata | null | undefined,
): AutomationStateMetadata | null {
if (!state || state.enabled !== true) {
return null
}
return {
enabled: true,
phase:
state.phase === 'standby' || state.phase === 'sleeping'
? state.phase
: null,
next_tick_at:
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
sleep_until:
typeof state.sleep_until === 'number' ? state.sleep_until : null,
}
}
function automationStateKey(
state: AutomationStateMetadata | null,
): string {
return JSON.stringify(state)
}
function applyMetadataUpdate(
metadata: SessionExternalMetadata,
): void {
const nextMetadata = { ...currentMetadata }
for (const key of Object.keys(metadata) as Array<
keyof SessionExternalMetadata
>) {
const value = metadata[key]
if (value === undefined) {
delete nextMetadata[key]
continue
}
;(nextMetadata as Record<string, unknown>)[key] = value
}
currentMetadata = nextMetadata
}
export function getSessionMetadataSnapshot(): SessionExternalMetadata {
const snapshot: SessionExternalMetadata = { ...currentMetadata }
if (currentAutomationState) {
snapshot.automation_state = { ...currentAutomationState }
} else if ('automation_state' in currentMetadata) {
snapshot.automation_state = currentMetadata.automation_state ?? null
}
return snapshot
}
export function getSessionState(): SessionState {
return currentState
@@ -101,18 +182,31 @@ export function notifySessionStateChanged(
// null on the next non-blocked transition.
if (state === 'requires_action' && details) {
hasPendingAction = true
metadataListener?.({
notifySessionMetadataChanged({
pending_action: details,
})
} else if (hasPendingAction) {
hasPendingAction = false
metadataListener?.({ pending_action: null })
notifySessionMetadataChanged({ pending_action: null })
}
// task_summary is written mid-turn by the forked summarizer; clear it at
// idle so the next turn doesn't briefly show the previous turn's progress.
if (state === 'idle') {
metadataListener?.({ task_summary: null })
notifySessionMetadataChanged({ task_summary: null })
}
if (state !== 'idle') {
notifyAutomationStateChanged(
isProactiveActive()
? {
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
}
: null,
)
}
// Mirror to the SDK event stream so non-CCR consumers (scmuxd, VS Code)
@@ -136,9 +230,25 @@ export function notifySessionStateChanged(
export function notifySessionMetadataChanged(
metadata: SessionExternalMetadata,
): void {
applyMetadataUpdate(metadata)
metadataListener?.(metadata)
}
export function notifyAutomationStateChanged(
state: AutomationStateMetadata | null | undefined,
): void {
const nextState = normalizeAutomationState(state)
if (
automationStateKey(nextState) === automationStateKey(currentAutomationState)
) {
return
}
currentAutomationState = nextState
applyMetadataUpdate({ automation_state: nextState })
metadataListener?.({ automation_state: nextState })
}
/**
* Fired by onChangeAppState when toolPermissionContext.mode changes.
* Downstream listeners (CCR external_metadata PUT, SDK status stream) are
@@ -148,3 +258,13 @@ export function notifySessionMetadataChanged(
export function notifyPermissionModeChanged(mode: PermissionMode): void {
permissionModeListener?.(mode)
}
export function resetSessionStateForTests(): void {
stateListener = null
metadataListener = null
permissionModeListener = null
hasPendingAction = false
currentState = 'idle'
currentAutomationState = null
currentMetadata = {}
}

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'crypto'
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
import type { Task } from './tasks.js'
export type TaskStateItem = Pick<
Task,
| 'id'
| 'subject'
| 'description'
| 'activeForm'
| 'status'
| 'owner'
| 'blocks'
| 'blockedBy'
>
export type TaskStateMessage = SDKMessage & {
type: 'task_state'
uuid: string
task_list_id: string
tasks: TaskStateItem[]
}
export type TaskStateSnapshot = Pick<
TaskStateMessage,
'task_list_id' | 'tasks'
>
function toTaskStateItem(task: Task): TaskStateItem {
return {
id: task.id,
subject: task.subject,
description: task.description,
activeForm: task.activeForm,
status: task.status,
owner: task.owner,
blocks: [...task.blocks],
blockedBy: [...task.blockedBy],
}
}
function compareTaskStateItems(a: TaskStateItem, b: TaskStateItem): number {
return a.id.localeCompare(b.id)
}
export function buildTaskStateSnapshot(
taskListId: string,
tasks: Task[],
): TaskStateSnapshot {
return {
task_list_id: taskListId,
tasks: tasks
.filter(task => !task.metadata?._internal)
.map(toTaskStateItem)
.sort(compareTaskStateItems),
}
}
export function getTaskStateSnapshotKey(
taskListId: string,
tasks: Task[],
): string {
return JSON.stringify(buildTaskStateSnapshot(taskListId, tasks))
}
export function buildTaskStateMessage(
taskListId: string,
tasks: Task[],
): TaskStateMessage {
const snapshot = buildTaskStateSnapshot(taskListId, tasks)
return {
type: 'task_state',
uuid: randomUUID(),
...snapshot,
}
}