fix: use bridge session lifecycle methods

This commit is contained in:
Haitao Pan 2026-04-24 10:09:20 +08:00
parent 5de94ca089
commit 90aaa084b0
8 changed files with 157 additions and 38 deletions

View File

@ -62,7 +62,7 @@
- Source: `lib/runtime/gateway_acp_client.dart`
- Type: `class`
- Responsibility:
描述一次 multi-agent thread/start / turn/start 请求。
描述一次 multi-agent session.start / session.message 请求。
### Constructor Parameters
@ -74,7 +74,7 @@
| `workingDirectory` | `String` | Yes | 工作目录 |
| `attachments` | `List<CollaborationAttachment>` | Yes | 本地文件附件 |
| `selectedSkills` | `List<String>` | Yes | 显式选中的技能键 |
| `resumeSession` | `bool` | Yes | `false` 时发 `thread/start``true` 时发 `turn/start` |
| `resumeSession` | `bool` | Yes | `false` 时发 `session.start``true` 时发 `session.message` |
## `GatewayAcpClient`

View File

@ -36,7 +36,7 @@ flowchart TD
subgraph CONTRACT["Bridge-facing contract"]
B1["acp.capabilities"]
B2["xworkmate.routing.resolve"]
B3["thread/start / turn/start / session.cancel / session.close"]
B3["session.start / session.message / session.cancel / session.close"]
B4["xworkmate.gateway.connect / request / disconnect"]
end

View File

@ -73,14 +73,14 @@ make check-api-external
- profile sync 元数据读取
- bridge capabilities 拉取
- routing resolve
- `thread/start`
- `turn/start`
- `session.start`
- `session.message`
- `session.cancel`
- `session.close`
## 5. 已知行为
- `thread/start` / `turn/start` 在当前环境下可能返回下游连接失败,这不代表脚本失效
- `session.start` / `session.message` 在当前环境下可能返回下游连接失败,这不代表脚本失效
- 只要脚本正确拿到 JSON-RPC 返回并解析出结果,就认为脚本可用
- 脚本不会把 `BRIDGE_SERVER_URL` 当成 runtime 真源;它只作为可选显式输入

View File

@ -6,7 +6,7 @@ Last Updated: 2026-04-22
- 账户服务 `accounts.svc.plus`
- 桥接服务 `xworkmate-bridge.svc.plus`
- 桥接侧 JSON-RPC 会话接口 `thread/start` / `turn/start` / `session.cancel` / `session.close`
- 桥接侧 JSON-RPC 会话接口 `session.start` / `session.message` / `session.cancel` / `session.close`
本文目标不是抽象协议说明,而是把 APP 真实会调用的接口、请求体、返回体、鉴权方式、已验证结果和当前风险点整理成可执行测试清单。
@ -20,8 +20,8 @@ Last Updated: 2026-04-22
- `acp.capabilities`
- `xworkmate.routing.resolve`
- 会话生命周期接口:
- `thread/start`
- `turn/start`
- `session.start`
- `session.message`
- `session.cancel`
- `session.close`
@ -79,8 +79,8 @@ Last Updated: 2026-04-22
| bridge | `/acp/rpc` | `POST` | `Authorization: Bearer <BRIDGE_AUTH_TOKEN>` | bridge JSON-RPC 主入口 | 所有运行时任务统一走这里 |
| bridge | `acp.capabilities` | JSON-RPC method | 同上 | 拉取 provider catalog / target catalog | capability 只读快照 |
| bridge | `xworkmate.routing.resolve` | JSON-RPC method | 同上 | 解析 provider / gateway / skills 路由 | 返回 resolved / unavailable 信息 |
| bridge | `thread/start` | JSON-RPC method | 同上 | 开启新会话 | session 生命周期起点 |
| bridge | `turn/start` | JSON-RPC method | 同上 | 继续现有会话 | 续写 / follow-up |
| bridge | `session.start` | JSON-RPC method | 同上 | 开启新会话 | session 生命周期起点 |
| bridge | `session.message` | JSON-RPC method | 同上 | 继续现有会话 | 续写 / follow-up |
| bridge | `session.cancel` | JSON-RPC method | 同上 | 取消正在进行的会话 | 终止流式任务 |
| bridge | `session.close` | JSON-RPC method | 同上 | 关闭会话 | 释放会话资源 |
@ -332,7 +332,7 @@ Last Updated: 2026-04-22
这些字段由 [`GoTaskServiceRequest.toExternalAcpParams()`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/go_task_service_client.dart) 生成。
### 6.2 `thread/start`
### 6.2 `session.start`
#### 目标
@ -345,7 +345,7 @@ Last Updated: 2026-04-22
{
"jsonrpc": "2.0",
"id": "1",
"method": "thread/start",
"method": "session.start",
"params": {
"sessionId": "test-session-001",
"threadId": "test-session-001",
@ -409,7 +409,7 @@ Last Updated: 2026-04-22
- 不能回退到本地 hardcoded endpoint 作为 APP runtime 真源
- 不能把 provider 路由走成 `/acp-server/*` 直连
### 6.3 `turn/start`
### 6.3 `session.message`
#### 目标
@ -422,7 +422,7 @@ Last Updated: 2026-04-22
{
"jsonrpc": "2.0",
"id": "2",
"method": "turn/start",
"method": "session.message",
"params": {
"sessionId": "test-session-001",
"threadId": "test-session-001",
@ -450,7 +450,7 @@ Last Updated: 2026-04-22
#### 返回体重点
- 与 `thread/start` 相同的会话结果字段
- 与 `session.start` 相同的会话结果字段
#### 当前实测结果
@ -548,7 +548,7 @@ Last Updated: 2026-04-22
| [`lib/runtime/account_runtime_client.dart`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/account_runtime_client.dart) | 账户登录、会话、同步接口封装 |
| [`lib/runtime/gateway_acp_client.dart`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/gateway_acp_client.dart) | bridge JSON-RPC 请求、capabilities、routing、session 生命周期 |
| [`lib/runtime/go_task_service_client.dart`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/go_task_service_client.dart) | 会话请求参数组装 |
| [`lib/runtime/external_code_agent_acp_desktop_transport.dart`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/external_code_agent_acp_desktop_transport.dart) | thread/start / turn/start 触发路径 |
| [`lib/runtime/external_code_agent_acp_desktop_transport.dart`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/lib/runtime/external_code_agent_acp_desktop_transport.dart) | session.start / session.message 触发路径 |
## 8. 自动化测试建议
@ -562,20 +562,20 @@ Last Updated: 2026-04-22
#### 桥接协议层
- `test/runtime/gateway_acp_client_session_test.dart`
- 覆盖 `thread/start`
- 覆盖 `turn/start`
- 覆盖 `session.start`
- 覆盖 `session.message`
- 覆盖 `session.cancel`
- 覆盖 `session.close`
- 断言 Bearer 头、JSON-RPC method、params 结构
#### 失败分支
- `thread/start` 下游连接失败时,返回值中应保留:
- `session.start` 下游连接失败时,返回值中应保留:
- `success: false`
- `error`
- `turnId`
- `resolvedProviderId`
- `turn/start` follow-up 失败时,不应破坏会话标识
- `session.message` follow-up 失败时,不应破坏会话标识
### 8.3 建议断言
@ -587,7 +587,7 @@ Last Updated: 2026-04-22
## 9. 当前已知风险
- `thread/start` / `turn/start` 当前环境下下游连接到 `127.0.0.1:9001` 失败
- `session.start` / `session.message` 当前环境下下游连接到 `127.0.0.1:9001` 失败
- 这说明 bridge 主入口可用,但后端 provider 适配层在当前运行环境里未就绪
- 这是 bridge 后端运行态问题,不是 APP 侧 JSON-RPC 协议错误
@ -598,8 +598,8 @@ Last Updated: 2026-04-22
3. `GET /api/auth/xworkmate/profile/sync`
4. `acp.capabilities`
5. `xworkmate.routing.resolve`
6. `thread/start`
7. `turn/start`
6. `session.start`
7. `session.message`
8. `session.cancel`
9. `session.close`
@ -610,6 +610,6 @@ Last Updated: 2026-04-22
- 账户侧负责登录与同步元数据
- bridge 侧负责 capability、路由解析和会话生命周期
- 任务执行必须统一经过 `/acp/rpc`
- `thread/start` / `turn/start` 的协议入口已验证通畅
- `session.start` / `session.message` 的协议入口已验证通畅
- `session.cancel` / `session.close` 的协议入口已验证可用
- 当前剩余风险集中在桥接后端下游 provider 连接,而不是 APP 侧接口拼接

View File

@ -105,7 +105,7 @@ class ExternalCodeAgentAcpDesktopTransport
);
}
final response = await _client.request(
method: request.resumeSession ? 'turn/start' : 'thread/start',
method: request.resumeSession ? 'session.message' : 'session.start',
params: request.toExternalAcpParams(),
endpointOverride: endpointOverride,
onNotification: (notification) {

View File

@ -145,7 +145,8 @@ class GatewayAcpClient {
singleAgent: singleAgent,
multiAgent: multiAgent,
availableExecutionTargets: _parseAvailableExecutionTargets(
result['availableExecutionTargets'] ?? caps['availableExecutionTargets'],
result['availableExecutionTargets'] ??
caps['availableExecutionTargets'],
singleAgent: singleAgent,
gatewayProviderCatalog: gatewayProviderCatalog,
),
@ -266,7 +267,7 @@ class GatewayAcpClient {
}
final rpcRequest = _GatewayAcpRpcRequest(
id: _nextRequestId('multi-agent'),
method: request.resumeSession ? 'turn/start' : 'thread/start',
method: request.resumeSession ? 'session.message' : 'session.start',
params: <String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,

View File

@ -136,7 +136,7 @@ capabilities_json="$(
-H "Authorization: Bearer ${bridge_auth_token}"
)"
start_payload='{"jsonrpc":"2.0","id":"start","method":"thread/start","params":{"sessionId":"scenario-session-001","threadId":"scenario-session-001","mode":"gateway-chat","taskPrompt":"Say hello in one short sentence.","workingDirectory":"/tmp","selectedSkills":[],"attachments":[],"provider":"codex","routing":{"routingMode":"auto","preferredGatewayTarget":"codex","explicitExecutionTarget":"agent","explicitProviderId":"codex","explicitModel":"","explicitSkills":[],"allowSkillInstall":false,"availableSkills":[]},"requestedExecutionTarget":"agent","executionTarget":"agent"}}'
start_payload='{"jsonrpc":"2.0","id":"start","method":"session.start","params":{"sessionId":"scenario-session-001","threadId":"scenario-session-001","mode":"gateway-chat","taskPrompt":"Say hello in one short sentence.","workingDirectory":"/tmp","selectedSkills":[],"attachments":[],"provider":"codex","routing":{"routingMode":"auto","preferredGatewayTarget":"codex","explicitExecutionTarget":"agent","explicitProviderId":"codex","explicitModel":"","explicitSkills":[],"allowSkillInstall":false,"availableSkills":[]},"requestedExecutionTarget":"agent","executionTarget":"agent"}}'
start_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
@ -153,12 +153,12 @@ payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result") or payload.get("payload") or {}
turn_id = str(result.get("turnId") or "").strip()
if not turn_id:
raise SystemExit("thread/start did not return turnId")
raise SystemExit("session.start did not return turnId")
print(turn_id)
PY
)"
message_payload='{"jsonrpc":"2.0","id":"message","method":"turn/start","params":{"sessionId":"scenario-session-001","threadId":"scenario-session-001","mode":"gateway-chat","taskPrompt":"Continue with a very short acknowledgement.","workingDirectory":"/tmp","selectedSkills":[],"attachments":[],"provider":"codex","routing":{"routingMode":"auto","preferredGatewayTarget":"codex","explicitExecutionTarget":"agent","explicitProviderId":"codex","explicitModel":"","explicitSkills":[],"allowSkillInstall":false,"availableSkills":[]},"requestedExecutionTarget":"agent","executionTarget":"agent"}}'
message_payload='{"jsonrpc":"2.0","id":"message","method":"session.message","params":{"sessionId":"scenario-session-001","threadId":"scenario-session-001","mode":"gateway-chat","taskPrompt":"Continue with a very short acknowledgement.","workingDirectory":"/tmp","selectedSkills":[],"attachments":[],"provider":"codex","routing":{"routingMode":"auto","preferredGatewayTarget":"codex","explicitExecutionTarget":"agent","explicitProviderId":"codex","explicitModel":"","explicitSkills":[],"allowSkillInstall":false,"availableSkills":[]},"requestedExecutionTarget":"agent","executionTarget":"agent"}}'
message_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
@ -207,9 +207,9 @@ import os
payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result") or payload.get("payload") or {}
if result.get("resolvedProviderId") != "codex":
raise SystemExit("thread/start did not resolve codex")
raise SystemExit("session.start did not resolve codex")
if not str(result.get("error") or "").strip():
raise SystemExit("thread/start in this environment should expose downstream error details")
raise SystemExit("session.start in this environment should expose downstream error details")
PY
RESPONSE_JSON="${message_json}" python3 - <<'PY'
@ -219,9 +219,9 @@ import os
payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result") or payload.get("payload") or {}
if result.get("turnId") == "":
raise SystemExit("turn/start did not return turnId")
raise SystemExit("session.message did not return turnId")
if str(result.get("turnId") or "").strip() == "":
raise SystemExit("turn/start did not return turnId")
raise SystemExit("session.message did not return turnId")
PY
RESPONSE_JSON="${cancel_json}" python3 - <<'PY'

View File

@ -347,11 +347,13 @@ void main() {
capture.requestBody,
contains('"requestedExecutionTarget":"gateway"'),
);
expect(capture.requestBody, contains('"method":"session.start"'));
expect(capture.requestBody, isNot(contains('"method":"thread/start"')));
},
);
test(
'desktop task execution uses thread/start instead of legacy session.start',
'desktop task execution uses session.start for new sessions',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
@ -374,16 +376,129 @@ void main() {
onUpdate: (_) {},
);
expect(capture.requestBody, contains('"method":"thread/start"'));
expect(capture.requestBody, isNot(contains('"method":"session.start"')));
expect(capture.requestBody, contains('"method":"session.start"'));
expect(capture.requestBody, isNot(contains('"method":"thread/start"')));
},
);
test('desktop follow-up execution uses session.message', () async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => capture.baseEndpoint,
taskEndpointResolver: (_) => capture.baseEndpoint,
);
await transport.executeTask(
_taskRequest(
target: AssistantExecutionTarget.agent,
provider: SingleAgentProvider.codex,
resumeSession: true,
),
onUpdate: (_) {},
);
expect(capture.requestBody, contains('"method":"session.message"'));
expect(capture.requestBody, isNot(contains('"method":"turn/start"')));
});
test('multi-agent execution uses session lifecycle methods', () async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final events = await client
.runMultiAgent(
const GatewayAcpMultiAgentRequest(
sessionId: 'session-1',
threadId: 'session-1',
prompt: 'hi',
workingDirectory: '/tmp',
attachments: <CollaborationAttachment>[],
selectedSkills: <String>[],
resumeSession: false,
),
)
.toList();
expect(events, isNotEmpty);
expect(
capture.requestBodies,
contains(
predicate<String>((body) {
return body.contains('"method":"session.start"');
}),
),
);
expect(
capture.requestBodies,
isNot(
contains(
predicate<String>((body) {
return body.contains('"method":"thread/start"');
}),
),
),
);
});
test('multi-agent follow-up uses session.message', () async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
await client
.runMultiAgent(
const GatewayAcpMultiAgentRequest(
sessionId: 'session-1',
threadId: 'session-1',
prompt: 'hi',
workingDirectory: '/tmp',
attachments: <CollaborationAttachment>[],
selectedSkills: <String>[],
resumeSession: true,
),
)
.toList();
expect(
capture.requestBodies,
contains(
predicate<String>((body) {
return body.contains('"method":"session.message"');
}),
),
);
expect(
capture.requestBodies,
isNot(
contains(
predicate<String>((body) {
return body.contains('"method":"turn/start"');
}),
),
),
);
});
});
}
GoTaskServiceRequest _taskRequest({
required AssistantExecutionTarget target,
required SingleAgentProvider provider,
bool resumeSession = false,
}) {
return GoTaskServiceRequest(
sessionId: 'session-1',
@ -399,6 +514,7 @@ GoTaskServiceRequest _taskRequest({
agentId: '',
metadata: const <String, dynamic>{},
provider: provider,
resumeSession: resumeSession,
);
}
@ -414,6 +530,7 @@ Future<_CapturedAcpHttpServer> _startAcpHttpServer() async {
capture.requestPath = request.uri.path;
final body = await utf8.decoder.bind(request).join();
capture.requestBody = body;
capture.requestBodies.add(body);
final id = _decodeRequestId(body);
request.response.headers.contentType = ContentType.json;
request.response.write(
@ -444,6 +561,7 @@ class _CapturedAcpHttpServer {
String authorizationHeader = '';
String requestPath = '';
String requestBody = '';
final List<String> requestBodies = <String>[];
Future<void> close() => _server.close(force: true);
}