From 270c8fd04ceae59dc35dec1f07f8862301c19f02 Mon Sep 17 00:00:00 2001 From: Hisakazu <750691178@qq.com> Date: Fri, 26 Jun 2026 20:15:02 +0800 Subject: [PATCH] Clarify adapter event stream decisions --- .../test/genericAdapterSample.test.mjs | 81 +++++++++++++++++++ docs/examples/generic-adapter/README.md | 9 ++- .../generic-adapter/generic-adapter.mjs | 38 ++++++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/apps/desktop/test/genericAdapterSample.test.mjs b/apps/desktop/test/genericAdapterSample.test.mjs index 05c397f..c87d80e 100644 --- a/apps/desktop/test/genericAdapterSample.test.mjs +++ b/apps/desktop/test/genericAdapterSample.test.mjs @@ -1309,6 +1309,13 @@ test("generic adapter sample summarizes event poll responses for a watch loop", assert.equal(state.has_more, true); assert.equal(state.should_poll_again, true); assert.equal(state.should_query_result, true); + assert.equal(state.must_query_results, true); + assert.deepEqual(state.next_poll, { + mode: "drain_page", + immediate: true, + after_event_id: "bridge-event-45", + reason: "has_more_events" + }); assert.equal(state.action_events.length, 2); assert.equal(state.action_state.lifecycle_status, "conflict"); assert.equal(state.action_state.final, true); @@ -1319,6 +1326,80 @@ test("generic adapter sample summarizes event poll responses for a watch loop", rmSync(tempRoot, { recursive: true, force: true }); }); +test("generic adapter sample event state asks for result lookup after terminal action", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nekodrop-generic-adapter-event-terminal-")); + const responsePath = join(tempRoot, "events-response.json"); + writeFileSync(responsePath, JSON.stringify({ + events_next_after_id: "bridge-event-46", + events_cursor_state: "ok", + events_has_more: false, + events: [ + { + kind: "action.updated", + payload: { + request_id: "adapter-send-001", + action_kind: "bundle.send", + status: "succeeded", + reason: null, + target_device_id: "device-a" + } + } + ] + })); + + const state = JSON.parse(execFileSync( + process.execPath, + [ + sampleCli, + "event-state", + "--response", + responsePath, + "--action-request-id", + "adapter-send-001" + ], + { encoding: "utf8" } + )); + + assert.equal(state.should_poll_again, false); + assert.equal(state.must_query_results, true); + assert.deepEqual(state.next_poll, { + mode: "query_results", + immediate: false, + after_event_id: "bridge-event-46", + reason: "terminal_action_event" + }); + + rmSync(tempRoot, { recursive: true, force: true }); +}); + +test("generic adapter sample event state resets missing cursors", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nekodrop-generic-adapter-event-missing-")); + const responsePath = join(tempRoot, "events-response.json"); + writeFileSync(responsePath, JSON.stringify({ + events_next_after_id: null, + events_cursor_state: "missing", + events_has_more: false, + events: [] + })); + + const state = JSON.parse(execFileSync( + process.execPath, + [sampleCli, "event-state", "--response", responsePath], + { encoding: "utf8" } + )); + + assert.equal(state.should_poll_again, true); + assert.equal(state.must_query_results, false); + assert.deepEqual(state.next_poll, { + mode: "reset_cursor", + immediate: true, + after_event_id: null, + reason: "cursor_missing" + }); + + rmSync(tempRoot, { recursive: true, force: true }); +}); + test("generic adapter sample can build action-scoped event poll requests", () => { const stdout = execFileSync( process.execPath, diff --git a/docs/examples/generic-adapter/README.md b/docs/examples/generic-adapter/README.md index 29c57ce..f3fe62a 100644 --- a/docs/examples/generic-adapter/README.md +++ b/docs/examples/generic-adapter/README.md @@ -410,7 +410,14 @@ node docs/examples/generic-adapter/generic-adapter.mjs event-state \ --action-request-id adapter-import-001 ``` -`event-state` 会返回下一次 cursor、是否应立即继续拉取、匹配的 `action.updated` 摘要、收到的 bundle id 数组和 transfer 事件数量。它不会替代 `actions.results`;终态事件出现后仍应按 `action_request_id` 精确查询结果。 +`event-state` 会返回下一次 cursor、`next_poll`、匹配的 `action.updated` 摘要、收到的 bundle id 数组和 transfer 事件数量。它不会替代 `actions.results`;终态事件出现后 `must_query_results=true`,仍应按 `action_request_id` 精确查询结果。 + +`next_poll.mode` 常见值: + +- `drain_page`:还有下一页,马上用 `after_event_id` 继续拉。 +- `reset_cursor`:cursor 丢失,丢弃本地 cursor 后从 `null` 重拉快照。 +- `query_results`:已经看到终态事件,先查 `actions.results`。 +- `wait`:没有新终态,按正常间隔或短等待继续。 事件处理建议: diff --git a/docs/examples/generic-adapter/generic-adapter.mjs b/docs/examples/generic-adapter/generic-adapter.mjs index a1db62a..e2010a6 100644 --- a/docs/examples/generic-adapter/generic-adapter.mjs +++ b/docs/examples/generic-adapter/generic-adapter.mjs @@ -1252,6 +1252,7 @@ function eventStateFromEventsResponse(flags) { .map((event) => event.payload); const latestAction = actionEvents.at(-1) ?? null; const latestActionState = latestAction ? actionEventState(latestAction) : null; + const nextPoll = nextEventPollDecision(response, latestActionState); return { cursor: nextCursorFromResponse(flags), stream_window: { @@ -1261,11 +1262,13 @@ function eventStateFromEventsResponse(flags) { }, event_count: events.length, has_more: Boolean(response.events_has_more), - should_poll_again: Boolean(response.events_has_more) || response.events_cursor_state === "missing", + should_poll_again: nextPoll.immediate, + next_poll: nextPoll, action_request_id: actionRequestId, action_state: latestActionState, action_events: actionEvents.map(actionEventState), should_query_result: Boolean(latestActionState?.final), + must_query_results: Boolean(latestActionState?.final), transfer_event_count: transferEvents.length, bundle_event_count: bundleEvents.length, received_bundle_ids: bundleEvents @@ -1274,6 +1277,39 @@ function eventStateFromEventsResponse(flags) { }; } +function nextEventPollDecision(response, latestActionState) { + if (response.events_cursor_state === "missing") { + return { + mode: "reset_cursor", + immediate: true, + after_event_id: null, + reason: "cursor_missing" + }; + } + if (Boolean(response.events_has_more)) { + return { + mode: "drain_page", + immediate: true, + after_event_id: response.events_next_after_id ?? null, + reason: "has_more_events" + }; + } + if (latestActionState?.final) { + return { + mode: "query_results", + immediate: false, + after_event_id: response.events_next_after_id ?? null, + reason: "terminal_action_event" + }; + } + return { + mode: "wait", + immediate: false, + after_event_id: response.events_next_after_id ?? null, + reason: latestActionState ? "action_not_terminal" : "no_matching_action_event" + }; +} + function actionEventState(event) { const lifecycle = event.status ?? "unknown"; return {