Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions packages/sdk/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe("Agent provider configuration", () => {
await agent.close()
})

test("auth_status reports the injected provider's apiType", async () => {
test("auth_status is no longer emitted for host-injected provider runs", async () => {
const provider = new CapturingProvider()
const agent = createAgent({ persistSession: false, tools: [], provider })

Expand All @@ -140,10 +140,7 @@ describe("Agent provider configuration", () => {
events.push(event)
}

const authStatus = events.find((event) => event.type === "auth_status") as any
expect(authStatus).toBeDefined()
expect(authStatus.error).toBeUndefined()
expect(authStatus.output).toEqual(["Using anthropic-messages credentials"])
expect(events.find((event) => event.type === "auth_status")).toBeUndefined()
await agent.close()
})

Expand Down Expand Up @@ -1347,3 +1344,42 @@ describe("Agent session persistence", () => {
await agent.close()
})
})

describe("auth_status emission", () => {
test("host-injected provider 不再发射 auth_status(软中止路径)", async () => {
const provider = new CapturingProvider()
const agent = createAgent({
persistSession: false,
tools: [],
provider,
model: "host/model-a",
})
const controller = new AbortController()
controller.abort(new Error("stopped"))

const events: SDKMessage[] = []
for await (const event of agent.query("hello", { abortSignal: controller.signal })) {
events.push(event)
}

expect(events.some((event) => event.type === "auth_status")).toBe(false)
await agent.close()
})

test("未注入 provider 的运行入口 fail-fast,不产生任何事件流", async () => {
const agent = createAgent({
persistSession: false,
tools: [],
model: "host/model-a",
})

const drain = async () => {
for await (const _event of agent.query("hello")) {
// drain
}
}
await expect(drain()).rejects.toThrow("No LLMProvider configured")

await agent.close()
})
})
13 changes: 4 additions & 9 deletions packages/sdk/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,15 +1065,9 @@ export class Agent {
yield queued
}

// A host-injected provider is guaranteed by the entry check above; the
// provider's own apiType is the authoritative protocol (cfg.apiType/env
// sniffing no longer exists to contradict it).
yield {
type: 'auth_status',
isAuthenticating: false,
output: [`Using ${provider.apiType} credentials`],
session_id: this.sid,
}
// No auth_status event: the entry check above guarantees a host-injected
// provider, the SDK owns no credentials, and no consumer ever read this
// event — it was pure dead weight on every run.

let persistedSessionEvent: SDKMessage | null = null
let compactionBoundarySeen = false
Expand Down Expand Up @@ -1344,6 +1338,7 @@ export class Agent {
return this.sid
}

/** Resolved API type of the active (host-injected or fallback) provider config. */
getApiType(): ApiType {
return this.provider.apiType
}
Expand Down
134 changes: 134 additions & 0 deletions packages/sdk/src/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createHookRegistry, HookRegistry, type HookDefinition, type HookInput } from "./hooks.js";

afterEach(() => {
// Silence the expected console.error output from failing hook paths.
console.error = originalConsoleError;
});
const originalConsoleError = console.error;

function silenceConsoleError(): void {
console.error = () => {};
}

function makeInput(overrides: Partial<HookInput> = {}): HookInput {
return { event: "PreToolUse", sessionId: "session-1", ...overrides };
}

describe("HookRegistry function handler timeouts", () => {
test("clears the timeout timer when the handler succeeds", async () => {
const registry = new HookRegistry();
registry.register("PreToolUse", {
handler: async () => ({ message: "done" }),
});

const originalSet = globalThis.setTimeout;
const originalClear = globalThis.clearTimeout;
const createdHandles: unknown[] = [];
const clearedHandles: unknown[] = [];
(globalThis as any).setTimeout = ((fn: any, ms?: number) => {
const handle = originalSet(fn, ms);
createdHandles.push(handle);
return handle;
}) as typeof setTimeout;
(globalThis as any).clearTimeout = ((handle: any) => {
if (handle != null) clearedHandles.push(handle);
return originalClear(handle);
}) as typeof clearTimeout;

try {
const result = await registry.executeDetailed("PreToolUse", makeInput());
expect(result.outputs).toEqual([{ message: "done" }]);
} finally {
globalThis.setTimeout = originalSet;
globalThis.clearTimeout = originalClear;
}

expect(createdHandles.length).toBeGreaterThan(0);
for (const handle of createdHandles) {
expect(clearedHandles).toContain(handle);
}
});

test("times out a hanging handler and reports an error hook_response", async () => {
silenceConsoleError();
const registry = new HookRegistry();
let releaseHandler!: () => void;
registry.register("PreToolUse", {
timeout: 20,
handler: async () => {
await new Promise<void>((resolve) => {
releaseHandler = resolve;
});
return { message: "too late" };
},
});

const result = await registry.executeDetailed("PreToolUse", makeInput());

const response = result.events.find(
(event) => event.subtype === "hook_response",
);
expect(response && "outcome" in response ? response.outcome : undefined).toBe("error");
expect(response && "stderr" in response ? response.stderr : "").toContain("Hook timeout");
expect(result.outputs).toHaveLength(0);

// Release the late handler so its defensive noop catch settles quietly.
releaseHandler();
await new Promise((resolve) => setTimeout(resolve, 10));
});
});

describe("HookRegistry matcher handling", () => {
function hook(name: string, calls: string[], extra: Partial<HookDefinition> = {}): HookDefinition {
return {
...extra,
handler: async () => {
calls.push(name);
return undefined;
},
};
}

test("an invalid matcher reports an error event and does not abort remaining hooks", async () => {
silenceConsoleError();
const registry = createHookRegistry();
const calls: string[] = [];
registry.register("PreToolUse", hook("broken", calls, { matcher: "[unclosed" }));
registry.register("PreToolUse", hook("read-only", calls, { matcher: "Read|Write" }));
registry.register("PreToolUse", hook("always", calls));

const result = await registry.executeDetailed(
"PreToolUse",
makeInput({ toolName: "Read" }),
);

expect(calls).toEqual(["read-only", "always"]);

const startedNames = result.events
.filter((event) => event.subtype === "hook_started")
.map((event) => event.hook_name);
expect(startedNames).not.toContain("[unclosed");

const failure = result.events.find(
(event) => event.subtype === "hook_response" && event.outcome === "error",
);
expect(failure).toBeTruthy();
expect(failure && "stderr" in failure ? failure.stderr : "").toContain("Invalid matcher");
});

test("a valid matcher still filters non-matching tools", async () => {
const registry = createHookRegistry();
const calls: string[] = [];
registry.register("PreToolUse", hook("read-only", calls, { matcher: "Read|Write" }));
registry.register("PreToolUse", hook("always", calls));

const result = await registry.executeDetailed(
"PreToolUse",
makeInput({ toolName: "Bash" }),
);

expect(calls).toEqual(["always"]);
expect(result.events.filter((event) => event.subtype === "hook_started")).toHaveLength(1);
});
});
49 changes: 40 additions & 9 deletions packages/sdk/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,36 @@ export class HookRegistry {
const events: HookExecutionResult['events'] = []

for (const def of definitions) {
const hookId = crypto.randomUUID()
const hookName = def.command || def.matcher || 'inline-hook'

// Check matcher for tool-specific hooks
if (def.matcher && input.toolName) {
const regex = new RegExp(def.matcher)
let regex: RegExp
try {
regex = new RegExp(def.matcher)
} catch (err: any) {
// An invalid matcher must not take down the whole event: report the
// def as failed and keep executing the remaining hooks.
const message = `Invalid matcher: ${err?.message || String(err)}`
console.error(`[Hook] ${event} hook failed: ${message}`)
events.push({
type: 'system',
subtype: 'hook_response',
hook_id: hookId,
hook_name: hookName,
hook_event: event,
output: '',
stdout: '',
stderr: message,
outcome: 'error',
session_id: input.sessionId || '',
})
continue
}
if (!regex.test(input.toolName)) continue
}

const hookId = crypto.randomUUID()
const hookName = def.command || def.matcher || 'inline-hook'
events.push({
type: 'system',
subtype: 'hook_started',
Expand All @@ -203,12 +225,21 @@ export class HookRegistry {

if (def.handler) {
// Function handler
output = await Promise.race([
def.handler(input),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('Hook timeout')), def.timeout || 30000),
),
])
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
try {
const handlerPromise = def.handler(input)
// Defensive: after a timeout, a late rejection of the handler must
// never surface as an unhandledRejection outside the race below.
handlerPromise.catch(() => {})
output = await Promise.race([
handlerPromise,
new Promise<void>((_, reject) => {
timeoutTimer = setTimeout(() => reject(new Error('Hook timeout')), def.timeout || 30000)
}),
])
} finally {
clearTimeout(timeoutTimer)
}
} else if (def.command) {
// Shell command handler
const shellResult = await executeShellHook(
Expand Down
5 changes: 2 additions & 3 deletions packages/sdk/src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,8 @@ export type CreateMessageStreamEvent =
*
* - **Protocol conversion.** Translate the normalized (Anthropic-like)
* request/response shapes in this file to and from the provider's native
* API. `apiType` declares which protocol; the engine surfaces it in
* `auth_status` and `getApiType()` but performs no protocol handling
* itself.
* API. `apiType` declares which protocol; the engine surfaces it via
* `getApiType()` but performs no protocol handling itself.
* - **Credentials & retry.** The provider owns keys, base URLs, and
* transport-level retries. The engine does not retry provider errors
* except its own prompt-too-long compaction path.
Expand Down
63 changes: 63 additions & 0 deletions packages/sdk/src/query-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { QueryController } from "./query-controller.js";

describe("QueryController input streaming", () => {
test("async iterable 输入源抛错时错误作为迭代器错误进入消息流", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);

async function* source(): AsyncGenerator<string> {
yield "first";
throw new Error("input source exploded");
}

const consumed: string[] = [];
const runner = async function* (inputs: AsyncIterable<string>) {
for await (const item of inputs) {
consumed.push(item);
}
};

const controller = new QueryController(
{} as any,
runner,
source(),
);

try {
const drain = async () => {
for await (const _event of controller) {
// drain
}
};
await expect(drain()).rejects.toThrow("input source exploded");
} finally {
process.off("unhandledRejection", onUnhandled);
}

expect(consumed).toEqual(["first"]);
expect(unhandled).toHaveLength(0);
});

test("标量初始输入照常投递并关闭输入队列", async () => {
const runner = async function* (inputs: AsyncIterable<string>) {
for await (const item of inputs) {
yield { type: "user", message: item };
}
yield { type: "system", subtype: "done" };
};

const controller = new QueryController({} as any, runner, "hello");
const events: Array<Record<string, unknown>> = [];
for await (const event of controller) {
events.push(event as Record<string, unknown>);
}

expect(events).toHaveLength(2);
expect(events[0]?.message).toBe("hello");
expect((controller as any).queue.closed).toBe(true);
});
});
Loading
Loading