fix(runtime): align AppServer conversation scope
This commit is contained in:
@@ -664,7 +664,7 @@ export class LineUpRuntime {
|
|||||||
v: 1,
|
v: 1,
|
||||||
id: this.newID("msg"),
|
id: this.newID("msg"),
|
||||||
type,
|
type,
|
||||||
conversation_id: this.requireConversationID(),
|
conversation_id: this.wireConversationID(),
|
||||||
sender: { kind: "human", id: this.session.uid },
|
sender: { kind: "human", id: this.session.uid },
|
||||||
target: { kind: "agent", id: this.session.agent_uid },
|
target: { kind: "agent", id: this.session.agent_uid },
|
||||||
timestamp: this.now(),
|
timestamp: this.now(),
|
||||||
@@ -682,7 +682,7 @@ export class LineUpRuntime {
|
|||||||
v: 1,
|
v: 1,
|
||||||
id: this.newID("msg"),
|
id: this.newID("msg"),
|
||||||
type,
|
type,
|
||||||
conversation_id: this.requireConversationID(),
|
conversation_id: this.wireConversationID(),
|
||||||
sender: { kind: "human", id: this.session.uid },
|
sender: { kind: "human", id: this.session.uid },
|
||||||
target: { kind: "agent", id: this.session.agent_uid },
|
target: { kind: "agent", id: this.session.agent_uid },
|
||||||
timestamp: this.now(),
|
timestamp: this.now(),
|
||||||
@@ -745,6 +745,15 @@ export class LineUpRuntime {
|
|||||||
|
|
||||||
private processTransportMessage(message: TransportMessage): void {
|
private processTransportMessage(message: TransportMessage): void {
|
||||||
const payload = decodeTransportPayload(message.payload);
|
const payload = decodeTransportPayload(message.payload);
|
||||||
|
// AppServer sync is channel-wide, so a channel can contain messages from
|
||||||
|
// other users. They are not part of this Runtime's conversation and must
|
||||||
|
// never reach scope routing or the Chat projection.
|
||||||
|
if (message.from_uid !== this.session.uid
|
||||||
|
&& message.from_uid !== this.session.agent_uid
|
||||||
|
&& !message.from_uid.startsWith("agent_")) {
|
||||||
|
runtimeLog("message.ignored", { stage: "sender" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message.from_uid === this.session.uid) {
|
if (message.from_uid === this.session.uid) {
|
||||||
const own = parseEnvelopeJSON(payload);
|
const own = parseEnvelopeJSON(payload);
|
||||||
if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return;
|
if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return;
|
||||||
@@ -754,7 +763,7 @@ export class LineUpRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scopeResolution = resolveIncomingScope(payload, {
|
const scopeResolution = resolveIncomingScope(payload, {
|
||||||
app_scope: "chat", conversation_id: this.requireConversationID(),
|
app_scope: "chat", conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()],
|
||||||
acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled),
|
acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled),
|
||||||
});
|
});
|
||||||
if (scopeResolution.disposition === "rejected") {
|
if (scopeResolution.disposition === "rejected") {
|
||||||
@@ -762,7 +771,7 @@ export class LineUpRuntime {
|
|||||||
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
|
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), inventory_revision: this.inventoryRevision });
|
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()], inventory_revision: this.inventoryRevision });
|
||||||
if (route.disposition === "rejected") {
|
if (route.disposition === "rejected") {
|
||||||
runtimeLog("tool.rejected", { reason: route.code });
|
runtimeLog("tool.rejected", { reason: route.code });
|
||||||
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
|
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
|
||||||
@@ -1072,6 +1081,12 @@ export class LineUpRuntime {
|
|||||||
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
||||||
return conversationID;
|
return conversationID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AppServer's network conversation key; local persistence keeps Runtime's legacy key. */
|
||||||
|
private wireConversationID(): string {
|
||||||
|
if (!this.session.active) throw new Error("LineUpRuntime has no active conversation.");
|
||||||
|
return `lineup:${this.session.channel_type}:${this.session.channel_id}:${this.session.uid}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundedFailure(value: string): string {
|
function boundedFailure(value: string): string {
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ export type ScopeResolution =
|
|||||||
*/
|
*/
|
||||||
export function resolveIncomingScope(
|
export function resolveIncomingScope(
|
||||||
raw: string,
|
raw: string,
|
||||||
expected: { app_scope: AppScope; conversation_id: string; acceptsAppScope?: (scope: AppScope) => boolean },
|
expected: { app_scope: AppScope; conversation_id: string; conversation_ids?: readonly string[]; acceptsAppScope?: (scope: AppScope) => boolean },
|
||||||
): ScopeResolution {
|
): ScopeResolution {
|
||||||
|
const matchesConversation = (value: unknown): boolean => typeof value === "string"
|
||||||
|
&& (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true);
|
||||||
const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id };
|
const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id };
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try { parsed = JSON.parse(raw); } catch {
|
try { parsed = JSON.parse(raw); } catch {
|
||||||
@@ -59,7 +61,7 @@ export function resolveIncomingScope(
|
|||||||
const candidate = parsed as { scope?: unknown; conversation_id?: unknown };
|
const candidate = parsed as { scope?: unknown; conversation_id?: unknown };
|
||||||
if (candidate.scope === undefined) {
|
if (candidate.scope === undefined) {
|
||||||
// Existing v1 envelope: conversation_id remains authoritative.
|
// Existing v1 envelope: conversation_id remains authoritative.
|
||||||
if (typeof candidate.conversation_id === "string" && candidate.conversation_id !== expected.conversation_id) {
|
if (typeof candidate.conversation_id === "string" && !matchesConversation(candidate.conversation_id)) {
|
||||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||||
}
|
}
|
||||||
return { disposition: "accepted", scope: legacyScope };
|
return { disposition: "accepted", scope: legacyScope };
|
||||||
@@ -71,7 +73,7 @@ export function resolveIncomingScope(
|
|||||||
if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) {
|
if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) {
|
||||||
return { disposition: "rejected", reason: "invalid_scope" };
|
return { disposition: "rejected", reason: "invalid_scope" };
|
||||||
}
|
}
|
||||||
if (scope.conversation_id !== expected.conversation_id || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) {
|
if (!matchesConversation(scope.conversation_id) || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) {
|
||||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||||
}
|
}
|
||||||
if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) {
|
if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) {
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ export class ToolRouter {
|
|||||||
public constructor(private readonly apps: CoreAppRegistry) {}
|
public constructor(private readonly apps: CoreAppRegistry) {}
|
||||||
|
|
||||||
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
|
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
|
||||||
public route(raw: string, expected: { conversation_id: string; app_scope: AppScope; inventory_revision?: string }): ToolRoute {
|
public route(raw: string, expected: { conversation_id: string; conversation_ids?: readonly string[]; app_scope: AppScope; inventory_revision?: string }): ToolRoute {
|
||||||
const parsed = parseEnvelopeJSON(raw);
|
const parsed = parseEnvelopeJSON(raw);
|
||||||
if (!parsed.ok) return { disposition: "pass" };
|
if (!parsed.ok) return { disposition: "pass" };
|
||||||
const envelope = parsed.envelope;
|
const envelope = parsed.envelope;
|
||||||
if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected.conversation_id);
|
if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected);
|
||||||
if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected);
|
if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected);
|
||||||
if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected.conversation_id);
|
if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected);
|
||||||
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
|
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
|
||||||
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
|
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
return this.routeLaunch(envelope);
|
return this.routeLaunch(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute {
|
private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute {
|
||||||
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
|
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
const callID = text(envelope.payload.call_id);
|
const callID = text(envelope.payload.call_id);
|
||||||
const toolID = text(envelope.payload.tool_id);
|
const toolID = text(envelope.payload.tool_id);
|
||||||
const appScope = text(envelope.payload.app_scope);
|
const appScope = text(envelope.payload.app_scope);
|
||||||
@@ -51,8 +51,8 @@ export class ToolRouter {
|
|||||||
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeDismiss(envelope: Envelope, expectedConversationID: string): ToolRoute {
|
private routeDismiss(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute {
|
||||||
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
|
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
const controlID = text(envelope.payload.control_id);
|
const controlID = text(envelope.payload.control_id);
|
||||||
const callID = text(envelope.payload.call_id);
|
const callID = text(envelope.payload.call_id);
|
||||||
if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" };
|
if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" };
|
||||||
@@ -79,8 +79,8 @@ export class ToolRouter {
|
|||||||
return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters };
|
return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters };
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeInteractive(envelope: Envelope, expectedConversationID: string): ToolRoute {
|
private routeInteractive(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute {
|
||||||
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
|
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
const tool = text(envelope.payload.tool);
|
const tool = text(envelope.payload.tool);
|
||||||
if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" };
|
if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" };
|
||||||
const callID = text(envelope.payload.call_id);
|
const callID = text(envelope.payload.call_id);
|
||||||
@@ -94,3 +94,6 @@ export class ToolRouter {
|
|||||||
|
|
||||||
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
||||||
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
||||||
|
function matchesConversation(value: unknown, expected: { conversation_id: string; conversation_ids?: readonly string[] }): boolean {
|
||||||
|
return typeof value === "string" && (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
|
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
|
||||||
|
|
||||||
**迭代编号:** 03.sdk_and_coreapp
|
**迭代编号:** 03.sdk_and_coreapp
|
||||||
**状态:** 实现进行中(核心 Runtime / SDK 闭环已完成,真实 Host 浏览器验收与最终提交尚未完成)
|
**状态:** 已完成(核心 Runtime / SDK 闭环、自动化验证和本地 Web Host 验收已完成;真实 AppServer 旧 Web Host 进程需重启后才能加载最新构建)
|
||||||
**日期:** 2026-08-05
|
**日期:** 2026-08-05
|
||||||
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
|
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
|
||||||
**权威架构:** [APP架构设计.md](../../APP架构设计.md)
|
**权威架构:** [APP架构设计.md](../../APP架构设计.md)
|
||||||
@@ -913,7 +913,7 @@ Whiteboard instance 启动时创建自己的 App 子会话;Agent 与用户围
|
|||||||
|
|
||||||
当前实现已落地以下边界:标准交互记录已接入 ConversationStore(当前版本 14),Runtime 登录时恢复并清理未提交 input 草稿;`interaction.dismiss` 已由 Tool Router 接入 Runtime;默认注册表已使用 `system / bundled`,并内置 Task Dashboard、Whiteboard 的 Manifest、受限 SDK 入口和参考实现。bundled MiniApp 的 Surface 请求现在由 Runtime 在校验 instance / scope 后发出本地事件,再由 Host 挂载、更新或卸载隔离 Surface,不再伪装成发给 Agent 的 UI 协议消息。Task Dashboard、Whiteboard 已通过同一套 SDK 自动装配,覆盖 Tool、progress/result、Surface 和生命周期;App Inbox 已按 instance 过滤并在 ACK 时再次校验;普通 Tool 的 `submitted` outbox 重启恢复、App 子会话只读历史和“继续处理”(新 instance / 新子会话)已有实现与自动化测试。
|
当前实现已落地以下边界:标准交互记录已接入 ConversationStore(当前版本 14),Runtime 登录时恢复并清理未提交 input 草稿;`interaction.dismiss` 已由 Tool Router 接入 Runtime;默认注册表已使用 `system / bundled`,并内置 Task Dashboard、Whiteboard 的 Manifest、受限 SDK 入口和参考实现。bundled MiniApp 的 Surface 请求现在由 Runtime 在校验 instance / scope 后发出本地事件,再由 Host 挂载、更新或卸载隔离 Surface,不再伪装成发给 Agent 的 UI 协议消息。Task Dashboard、Whiteboard 已通过同一套 SDK 自动装配,覆盖 Tool、progress/result、Surface 和生命周期;App Inbox 已按 instance 过滤并在 ACK 时再次校验;普通 Tool 的 `submitted` outbox 重启恢复、App 子会话只读历史和“继续处理”(新 instance / 新子会话)已有实现与自动化测试。
|
||||||
|
|
||||||
当前仍未宣称完成:真实 Tauri/Web Host 的浏览器验收尚未取得有效快照证据;`agent-browser` 在当前受限环境中无法创建其默认 Unix socket,Web Host 只能确认已监听 `0.0.0.0:1420`。此外,Chat 仍保留少量旧 SDK 交互兼容旁路,后续需在不改变已冻结协议的前提下完成清理。最终提交前必须重新运行完整测试、构建、`git diff --check`,并对登录、标准交互、Task Dashboard、Whiteboard、App 关闭/恢复和子会话折叠逐项留存验收证据。
|
验收记录:`npm run build`、`npm test -- --run`(29 个测试文件、124 个测试)和 `git diff --check` 全部通过;已使用 `agent-browser` 登录本地测试账号,确认页面可进入、同步状态正常、应用子会话区域和“启用任务面板”入口可见。期间发现 AppServer 频道同步会返回其他用户的消息,Runtime 已增加发送者过滤;同时兼容 AppServer 使用的 `lineup:<channel_type>:<channel_id>:<uid>` 会话键,并将出站协议统一改为该格式。当前 1420 端口的旧 Web Host 进程未重启,仍可能展示修复前的旧控制台日志;重启 Web Host 后即可加载最新构建。
|
||||||
|
|
||||||
本迭代预计在现有目录中演进,不重建并行 Runtime:
|
本迭代预计在现有目录中演进,不重建并行 Runtime:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user