Skip to content

Commit a958558

Browse files
authored
Merge ae2c0cd into a341ae2
2 parents a341ae2 + ae2c0cd commit a958558

18 files changed

Lines changed: 831 additions & 51 deletions

packages/sdk/src/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,24 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] {
5959
const status = typeof record.status === "string" ? record.status.toLowerCase() : undefined;
6060
const stopReason = typeof record.stopReason === "string" ? record.stopReason.toLowerCase() : "";
6161
const pendingError = record.pendingError === true;
62+
const timeoutPhase =
63+
typeof record.timeoutPhase === "string" ? record.timeoutPhase.toLowerCase() : undefined;
64+
const statusAlreadyTimeoutAttributed = status === "timeout" || status === "timed_out";
65+
const hardTimeout =
66+
!pendingError &&
67+
((record.providerStarted === true && statusAlreadyTimeoutAttributed) ||
68+
timeoutPhase === "preflight" ||
69+
timeoutPhase === "provider" ||
70+
timeoutPhase === "post_turn");
6271
const hasTerminalTimeoutMetadata =
6372
readOptionalTimestamp(record.endedAt) !== undefined ||
6473
(!pendingError && readOptionalString(record.error) !== undefined) ||
6574
stopReason.length > 0 ||
6675
typeof record.livenessState === "string" ||
6776
record.yielded === true;
77+
if (hardTimeout) {
78+
return "timed_out";
79+
}
6880
if (
6981
status === "aborted" ||
7082
status === "cancelled" ||

packages/sdk/src/index.test.ts

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,79 @@ describe("OpenClaw SDK", () => {
143143
expect(result.error?.message).toBe("aborted by operator");
144144
});
145145

146+
it("maps provider-started rpc timeout wait snapshots to timed_out", async () => {
147+
const transport = new FakeTransport({
148+
"agent.wait": {
149+
status: "timeout",
150+
runId: "run_hard_timeout",
151+
stopReason: "rpc",
152+
timeoutPhase: "provider",
153+
providerStarted: true,
154+
error: "provider request timed out",
155+
},
156+
});
157+
const oc = new OpenClaw({ transport });
158+
159+
const result = await oc.runs.wait("run_hard_timeout");
160+
161+
expect(result.runId).toBe("run_hard_timeout");
162+
expect(result.status).toBe("timed_out");
163+
expect(result.error?.message).toBe("provider request timed out");
164+
});
165+
166+
it("maps provider timeout wait errors to timed_out", async () => {
167+
const transport = new FakeTransport({
168+
"agent.wait": {
169+
status: "error",
170+
runId: "run_timeout_error",
171+
timeoutPhase: "provider",
172+
providerStarted: true,
173+
error: "provider request timed out",
174+
},
175+
});
176+
const oc = new OpenClaw({ transport });
177+
178+
const result = await oc.runs.wait("run_timeout_error");
179+
180+
expect(result.runId).toBe("run_timeout_error");
181+
expect(result.status).toBe("timed_out");
182+
expect(result.error?.message).toBe("provider request timed out");
183+
});
184+
185+
it("does not map provider-started wait errors to timed_out without timeout attribution", async () => {
186+
const transport = new FakeTransport({
187+
"agent.wait": {
188+
status: "error",
189+
runId: "run_provider_error",
190+
providerStarted: true,
191+
error: "provider authentication failed",
192+
},
193+
});
194+
const oc = new OpenClaw({ transport });
195+
196+
const result = await oc.runs.wait("run_provider_error");
197+
198+
expect(result.runId).toBe("run_provider_error");
199+
expect(result.status).toBe("failed");
200+
expect(result.error?.message).toBe("provider authentication failed");
201+
});
202+
203+
it("does not treat successful provider-started wait snapshots as timed_out", async () => {
204+
const transport = new FakeTransport({
205+
"agent.wait": {
206+
status: "ok",
207+
runId: "run_provider_started_ok",
208+
providerStarted: true,
209+
},
210+
});
211+
const oc = new OpenClaw({ transport });
212+
213+
const result = await oc.runs.wait("run_provider_started_ok");
214+
215+
expect(result.runId).toBe("run_provider_started_ok");
216+
expect(result.status).toBe("completed");
217+
});
218+
146219
it("maps auth-revoked wait snapshots to cancelled", async () => {
147220
const transport = new FakeTransport({
148221
"agent.wait": {
@@ -192,6 +265,26 @@ describe("OpenClaw SDK", () => {
192265
expect(result.error?.message).toBe("429 RESOURCE_EXHAUSTED");
193266
});
194267

268+
it("keeps provider-attributed pending-error wait deadlines non-terminal", async () => {
269+
const transport = new FakeTransport({
270+
"agent.wait": {
271+
status: "timeout",
272+
runId: "run_pending_provider_error",
273+
error: "provider request timed out",
274+
pendingError: true,
275+
timeoutPhase: "provider",
276+
providerStarted: true,
277+
},
278+
});
279+
const oc = new OpenClaw({ transport });
280+
281+
const result = await oc.runs.wait("run_pending_provider_error");
282+
283+
expect(result.runId).toBe("run_pending_provider_error");
284+
expect(result.status).toBe("accepted");
285+
expect(result.error?.message).toBe("provider request timed out");
286+
});
287+
195288
it("maps terminal runtime timeout snapshots to timed_out", async () => {
196289
const transport = new FakeTransport({
197290
"agent.wait": {
@@ -1025,9 +1118,123 @@ describe("OpenClaw SDK", () => {
10251118
expect(cancelled.runId).toBe("run_1");
10261119
expect(cancelled.data).toEqual({ phase: "end", aborted: true, stopReason: "rpc" });
10271120

1028-
const authRevoked = normalizeGatewayEvent({
1121+
const hardTimeout = normalizeGatewayEvent({
10291122
event: "agent",
10301123
seq: 6,
1124+
payload: {
1125+
runId: "run_1",
1126+
stream: "lifecycle",
1127+
ts,
1128+
data: {
1129+
phase: "end",
1130+
aborted: true,
1131+
stopReason: "rpc",
1132+
timeoutPhase: "provider",
1133+
providerStarted: true,
1134+
},
1135+
},
1136+
});
1137+
expect(hardTimeout.type).toBe("run.timed_out");
1138+
expect(hardTimeout.runId).toBe("run_1");
1139+
expect(hardTimeout.data).toEqual({
1140+
phase: "end",
1141+
aborted: true,
1142+
stopReason: "rpc",
1143+
timeoutPhase: "provider",
1144+
providerStarted: true,
1145+
});
1146+
1147+
const hardTimeoutError = normalizeGatewayEvent({
1148+
event: "agent",
1149+
seq: 7,
1150+
payload: {
1151+
runId: "run_1",
1152+
stream: "lifecycle",
1153+
ts,
1154+
data: {
1155+
phase: "error",
1156+
error: "provider request timed out",
1157+
timeoutPhase: "provider",
1158+
providerStarted: true,
1159+
},
1160+
},
1161+
});
1162+
expect(hardTimeoutError.type).toBe("run.timed_out");
1163+
expect(hardTimeoutError.runId).toBe("run_1");
1164+
expect(hardTimeoutError.data).toEqual({
1165+
phase: "error",
1166+
error: "provider request timed out",
1167+
timeoutPhase: "provider",
1168+
providerStarted: true,
1169+
});
1170+
1171+
const providerStartedError = normalizeGatewayEvent({
1172+
event: "agent",
1173+
seq: 8,
1174+
payload: {
1175+
runId: "run_1",
1176+
stream: "lifecycle",
1177+
ts,
1178+
data: {
1179+
phase: "error",
1180+
error: "provider authentication failed",
1181+
providerStarted: true,
1182+
},
1183+
},
1184+
});
1185+
expect(providerStartedError.type).toBe("run.failed");
1186+
expect(providerStartedError.runId).toBe("run_1");
1187+
expect(providerStartedError.data).toEqual({
1188+
phase: "error",
1189+
error: "provider authentication failed",
1190+
providerStarted: true,
1191+
});
1192+
1193+
const hardTimeoutEnd = normalizeGatewayEvent({
1194+
event: "agent",
1195+
seq: 9,
1196+
payload: {
1197+
runId: "run_1",
1198+
stream: "lifecycle",
1199+
ts,
1200+
data: {
1201+
phase: "end",
1202+
timeoutPhase: "provider",
1203+
providerStarted: true,
1204+
},
1205+
},
1206+
});
1207+
expect(hardTimeoutEnd.type).toBe("run.timed_out");
1208+
expect(hardTimeoutEnd.runId).toBe("run_1");
1209+
expect(hardTimeoutEnd.data).toEqual({
1210+
phase: "end",
1211+
timeoutPhase: "provider",
1212+
providerStarted: true,
1213+
});
1214+
1215+
const providerStartedEnd = normalizeGatewayEvent({
1216+
event: "agent",
1217+
seq: 10,
1218+
payload: {
1219+
runId: "run_1",
1220+
stream: "lifecycle",
1221+
ts,
1222+
data: {
1223+
phase: "end",
1224+
providerStarted: true,
1225+
},
1226+
},
1227+
});
1228+
expect(providerStartedEnd.type).toBe("run.completed");
1229+
expect(providerStartedEnd.runId).toBe("run_1");
1230+
expect(providerStartedEnd.data).toEqual({
1231+
phase: "end",
1232+
providerStarted: true,
1233+
});
1234+
1235+
const authRevoked = normalizeGatewayEvent({
1236+
event: "agent",
1237+
seq: 10,
10311238
payload: {
10321239
runId: "run_1",
10331240
stream: "lifecycle",
@@ -1045,7 +1252,7 @@ describe("OpenClaw SDK", () => {
10451252

10461253
const timedOut = normalizeGatewayEvent({
10471254
event: "agent",
1048-
seq: 7,
1255+
seq: 11,
10491256
payload: {
10501257
runId: "run_1",
10511258
stream: "lifecycle",

packages/sdk/src/normalize.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,24 @@ function readLowerString(value: unknown): string | undefined {
1616
return readString(value)?.toLowerCase();
1717
}
1818

19+
function hasHardTimeoutMetadata(data: JsonObject, statusAlreadyTimeoutAttributed = false): boolean {
20+
const timeoutPhase = readLowerString(data.timeoutPhase);
21+
return (
22+
(statusAlreadyTimeoutAttributed && data.providerStarted === true) ||
23+
timeoutPhase === "preflight" ||
24+
timeoutPhase === "provider" ||
25+
timeoutPhase === "post_turn"
26+
);
27+
}
28+
1929
function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType {
2030
const status = readLowerString(data.status);
2131
const stopReason = readLowerString(data.stopReason);
32+
const statusAlreadyTimeoutAttributed =
33+
status === "timeout" || status === "timed_out" || data.aborted === true;
34+
if (hasHardTimeoutMetadata(data, statusAlreadyTimeoutAttributed)) {
35+
return "run.timed_out";
36+
}
2237
if (
2338
status === "aborted" ||
2439
status === "cancelled" ||
@@ -71,6 +86,9 @@ function normalizeAgentEventType(payload: JsonObject): OpenClawEventType {
7186
return normalizeLifecycleEndEventType(data);
7287
}
7388
if (phase === "error") {
89+
if (hasHardTimeoutMetadata(data, false)) {
90+
return "run.timed_out";
91+
}
7492
return "run.failed";
7593
}
7694
}

src/agents/agent-run-terminal-outcome.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ describe("agent run terminal outcome", () => {
7272
});
7373
});
7474

75+
it("does not treat successful provider-started metadata as timeout without attribution phase", () => {
76+
expect(
77+
buildAgentRunTerminalOutcome({
78+
status: "ok",
79+
providerStarted: true,
80+
}),
81+
).toEqual({
82+
reason: "completed",
83+
status: "ok",
84+
providerStarted: true,
85+
});
86+
});
87+
88+
it("does not treat provider-started errors as timeouts without timeout attribution", () => {
89+
expect(
90+
buildAgentRunTerminalOutcome({
91+
status: "error",
92+
error: "provider authentication failed",
93+
stopReason: "error",
94+
providerStarted: true,
95+
}),
96+
).toMatchObject({
97+
reason: "failed",
98+
status: "error",
99+
error: "provider authentication failed",
100+
providerStarted: true,
101+
});
102+
});
103+
75104
it("prefers hard timeout evidence over default rpc cancellation metadata", () => {
76105
const timeout = buildAgentRunTerminalOutcome({
77106
status: "timeout",
@@ -90,6 +119,52 @@ describe("agent run terminal outcome", () => {
90119
expect(mergeAgentRunTerminalOutcome(timeout, earlierCompletion)).toBe(earlierCompletion);
91120
});
92121

122+
it("classifies provider timeout lifecycle errors as hard timeouts", () => {
123+
expect(
124+
buildAgentRunTerminalOutcome({
125+
status: "error",
126+
error: "provider request timed out",
127+
stopReason: "error",
128+
timeoutPhase: "provider",
129+
providerStarted: true,
130+
}),
131+
).toMatchObject({
132+
reason: "hard_timeout",
133+
status: "timeout",
134+
error: "provider request timed out",
135+
});
136+
});
137+
138+
it("classifies timeout attribution metadata as a hard timeout even on end events", () => {
139+
expect(
140+
buildAgentRunTerminalOutcome({
141+
status: "ok",
142+
timeoutPhase: "provider",
143+
providerStarted: true,
144+
}),
145+
).toMatchObject({
146+
reason: "hard_timeout",
147+
status: "timeout",
148+
});
149+
});
150+
151+
it("lets timeout attribution outrank blocked liveness", () => {
152+
expect(
153+
buildAgentRunTerminalOutcome({
154+
status: "error",
155+
error: "provider request timed out",
156+
livenessState: "blocked",
157+
timeoutPhase: "provider",
158+
providerStarted: true,
159+
}),
160+
).toMatchObject({
161+
reason: "hard_timeout",
162+
status: "timeout",
163+
error: "provider request timed out",
164+
livenessState: "blocked",
165+
});
166+
});
167+
93168
it("keeps a hard timeout over later aborts or failures for the same run", () => {
94169
const timeout = buildAgentRunTerminalOutcome({
95170
status: "timeout",

0 commit comments

Comments
 (0)