Skip to content

Commit 03dec8b

Browse files
authored
fix(openai): avoid replay ids when Responses store is disabled
Avoid replaying prior OpenAI Responses reasoning/message/function-call item ids when the outgoing request disables store, while preserving encrypted reasoning and normalized summary arrays for stateless replay. Keep explicit store-enabled OpenAI wrapper paths opted into item-id replay, and cover shared/simple Responses, ChatGPT/Codex Responses, and GitHub Copilot sanitizer behavior. Regression tests cover store-disabled id omission, encrypted reasoning preservation, idless Copilot reasoning replay, and direct builder payloads. Local proof included focused Vitest, broad lint, broad test-types, bundled-extension lint, plugin boundary checks, autoreview clean, and live OpenAI Responses gpt-5.5 proof. Co-authored-by: hang <[email protected]>
1 parent 5bc80db commit 03dec8b

33 files changed

Lines changed: 867 additions & 164 deletions

extensions/feishu/runtime-api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ export type {
1818
ReplyPayload,
1919
} from "openclaw/plugin-sdk/core";
2020
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
21-
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
21+
export type RuntimeEnv = {
22+
log: (...args: unknown[]) => void;
23+
error: (...args: unknown[]) => void;
24+
exit: (code: number) => void;
25+
};
2226
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-contracts";
2327
export {
2428
DEFAULT_ACCOUNT_ID,

extensions/feishu/src/monitor-state-runtime-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
1+
export type { RuntimeEnv } from "../runtime-api.js";
22
export {
33
createFixedWindowRateLimiter,
44
createWebhookAnomalyTracker,

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
575575
const queueIdleSideEffects = (options?: { markClosedForReply?: boolean }): Promise<void> => {
576576
const nextIdleSideEffects = idleSideEffectsPromise.then(async () => {
577577
await closeStreaming(options);
578-
await typingCallbacks?.onIdle?.();
578+
await Promise.resolve(typingCallbacks?.onIdle?.());
579579
});
580580
idleSideEffectsPromise = nextIdleSideEffects.catch(() => {});
581581
return nextIdleSideEffects;
@@ -609,7 +609,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
609609
if (streamingEnabled && renderMode === "card") {
610610
startStreaming();
611611
}
612-
await typingCallbacks?.onReplyStart?.();
612+
await Promise.resolve(typingCallbacks?.onReplyStart?.());
613613
},
614614
deliver: async (payload: ReplyPayload, info) => {
615615
if (info?.kind === "final") {

extensions/github-copilot/connection-bound-ids.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("github-copilot connection-bound response IDs", () => {
6464
expect(input.map((item) => item.id)).toEqual([withEncrypted, withoutEncrypted]);
6565
});
6666

67-
it("drops unsafe reasoning replay items instead of stripping their IDs", () => {
67+
it("drops unsafe reasoning replay item IDs while keeping idless reasoning replay", () => {
6868
const overlongId = `5PX6gLHXT5wE+Y2tPmUV4gn+${"B".repeat(384)}`;
6969
const input = [
7070
{
@@ -80,6 +80,7 @@ describe("github-copilot connection-bound response IDs", () => {
8080

8181
expect(sanitizeCopilotReplayResponseIds(input)).toBe(true);
8282
expect(input).toEqual([
83+
{ type: "reasoning", encrypted_content: "missing-id", summary: [] },
8384
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
8485
]);
8586
});

extensions/github-copilot/connection-bound-ids.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
4444
continue;
4545
}
4646
const id = item.id;
47-
// Reasoning items always reference server-side encrypted state bound to the
48-
// original item ID. Rewriting or stripping that ID can turn replay into an
49-
// invalid or ambiguous server-state lookup, so drop unsafe reasoning items.
47+
// Reasoning items with replay IDs reference server-side encrypted state
48+
// bound to that ID. Drop unsafe IDs, but keep the store-disabled idless
49+
// replay form produced by core Responses conversion.
5050
if (item.type === "reasoning") {
51-
if (!isValidReasoningReplayId(id)) {
51+
if (id !== undefined && !isValidReasoningReplayId(id)) {
5252
input.splice(index, 1);
5353
rewrote = true;
5454
}

extensions/github-copilot/stream.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe("wrapCopilotAnthropicStream", () => {
127127
const payload = {
128128
input: [
129129
{ id: reasoningId, type: "reasoning", encrypted_content: "valid-encrypted-payload" },
130+
{ type: "reasoning", encrypted_content: "idless-encrypted-payload", summary: [] },
130131
{
131132
id: overlongReasoningId,
132133
type: "reasoning",
@@ -181,8 +182,13 @@ describe("wrapCopilotAnthropicStream", () => {
181182
onPayload: options.onPayload,
182183
});
183184
expect(payloads[0]?.input[0]?.id).toBe(reasoningId);
184-
expect(payloads[0]?.input.map((item) => item.type)).toEqual(["reasoning", "message"]);
185-
expect(payloads[0]?.input[1]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
185+
expect(payloads[0]?.input.map((item) => item.type)).toEqual([
186+
"reasoning",
187+
"reasoning",
188+
"message",
189+
]);
190+
expect(payloads[0]?.input[1]?.id).toBeUndefined();
191+
expect(payloads[0]?.input[2]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
186192
});
187193

188194
it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => {

extensions/telegram/src/polling-session.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import os from "node:os";
33
import path from "node:path";
44
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
55
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
6-
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7-
import { createChannelIngressQueue } from "../../../src/channels/message/ingress-queue.js";
8-
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../../src/infra/kysely-sync.js";
9-
import type { DB as OpenClawStateKyselyDatabase } from "../../../src/state/openclaw-state-db.generated.js";
106
import {
117
closeOpenClawStateDatabaseForTest,
8+
createChannelIngressQueueForTests as createChannelIngressQueue,
9+
executeSqliteQuerySync,
10+
getNodeSqliteKysely,
1211
openOpenClawStateDatabase,
13-
} from "../../../src/state/openclaw-state-db.js";
12+
type OpenClawStateKyselyDatabaseForTests,
13+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
14+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
1415
import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js";
1516
import type { TelegramRuntime } from "./runtime.types.js";
1617
import type { TelegramIngressWorkerMessage } from "./telegram-ingress-worker.js";
@@ -105,7 +106,10 @@ type WorkerPollErrorListener = (message: {
105106
type WorkerMessageListener = (message: TelegramIngressWorkerMessage) => void;
106107
type AsyncVoidFn = () => Promise<void>;
107108
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
108-
type TelegramPollingTestDatabase = Pick<OpenClawStateKyselyDatabase, "channel_ingress_events">;
109+
type TelegramPollingTestDatabase = Pick<
110+
OpenClawStateKyselyDatabaseForTests,
111+
"channel_ingress_events"
112+
>;
109113

110114
const POLLING_TEST_WATCHDOG_INTERVAL_MS = 30_000;
111115

@@ -115,7 +119,7 @@ function installTelegramIngressQueueRuntime(resolveStateDir: () => string): void
115119
resolveStateDir,
116120
openChannelIngressQueue: (
117121
options?: Omit<Parameters<typeof createChannelIngressQueue>[0], "channelId">,
118-
) => createChannelIngressQueue({ ...(options ?? {}), channelId: "telegram" }),
122+
) => createChannelIngressQueue({ ...options, channelId: "telegram" }),
119123
},
120124
} as TelegramRuntime);
121125
}

extensions/telegram/src/telegram-ingress-spool.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4+
import {
5+
closeOpenClawStateDatabaseForTest,
6+
createChannelIngressQueueForTests as createChannelIngressQueue,
7+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
48
import { afterEach, describe, expect, it } from "vitest";
5-
import { createChannelIngressQueue } from "../../../src/channels/message/ingress-queue.js";
6-
import { closeOpenClawStateDatabaseForTest } from "../../../src/state/openclaw-state-db.js";
79
import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js";
810
import type { TelegramRuntime } from "./runtime.types.js";
911
import {
@@ -25,7 +27,7 @@ function installTelegramIngressQueueRuntime(resolveStateDir: () => string): void
2527
resolveStateDir,
2628
openChannelIngressQueue: (
2729
options?: Omit<Parameters<typeof createChannelIngressQueue>[0], "channelId">,
28-
) => createChannelIngressQueue({ ...(options ?? {}), channelId: "telegram" }),
30+
) => createChannelIngressQueue({ ...options, channelId: "telegram" }),
2931
},
3032
} as TelegramRuntime);
3133
}

extensions/telegram/src/telegram-ingress-spool.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { randomUUID } from "node:crypto";
22
import os from "node:os";
33
import path from "node:path";
4-
import {
5-
type ChannelIngressQueue,
6-
type ChannelIngressQueueClaim,
7-
type ChannelIngressQueueClaimRef,
8-
type ChannelIngressQueueRecord,
4+
import type {
5+
ChannelIngressQueue,
6+
ChannelIngressQueueClaim,
7+
ChannelIngressQueueClaimRef,
8+
ChannelIngressQueueRecord,
99
} from "openclaw/plugin-sdk/channel-outbound";
1010
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
1111
import { getTelegramRuntime } from "./runtime.js";

extensions/voice-call/doctor-contract-api.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ function installStateRuntime(): void {
4040
}) as never,
4141
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
4242
createPluginStateSyncKeyedStoreForTests("voice-call", options),
43+
openChannelIngressQueue: (() => {
44+
throw new Error("openChannelIngressQueue is not used by voice-call doctor tests");
45+
}) as never,
4346
},
4447
});
4548
}

0 commit comments

Comments
 (0)