From e221615112a4c9c0000d700ef9064a7648ff58c7 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 23 Sep 2026 15:32:41 +0800
Subject: [PATCH] test(sdk): cover native dynamic tool lifecycle
---
python/development.i18n.yaml | 4 +-
python/development.md | 2 +-
python/development.zh.md | 2 +-
scripts/fixtures/python-sdk-dynamic-tools.mjs | 28 ++
scripts/smoke-python-runtime.py | 134 +++++++++-
.../dynamic-tools/tool-history.json | 248 ++++++++++++++++++
.../notifications.expected.jsonl | 29 ++
.../dynamic-tool-updates/result.expected.json | 1 +
.../sdk/dynamic-tool-updates/session.v4.jsonl | 31 +++
.../sdk/dynamic-tool-updates/snapshot.yml | 8 +
.../dynamic-tool-updates/workspace/task.txt | 1 +
snapshots/sdk/sdk.snapshot.ts | 37 +++
12 files changed, 516 insertions(+), 9 deletions(-)
create mode 100644 scripts/fixtures/python-sdk-dynamic-tools.mjs
create mode 100644 scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json
create mode 100644 snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl
create mode 100644 snapshots/sdk/dynamic-tool-updates/result.expected.json
create mode 100644 snapshots/sdk/dynamic-tool-updates/session.v4.jsonl
create mode 100644 snapshots/sdk/dynamic-tool-updates/snapshot.yml
create mode 100644 snapshots/sdk/dynamic-tool-updates/workspace/task.txt
diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml
index 87d4d3503b..a6b5fb5376 100644
--- a/python/development.i18n.yaml
+++ b/python/development.i18n.yaml
@@ -9,8 +9,8 @@
en: 8e99640e4936332d
zh: 729d5de1e0ad2869
/python-contributor-workflows/validate-the-sdk:
- en: a5d58453392437d2
- zh: aa3fa84703d894da
+ en: fbdd6bb379edd159
+ zh: f3a803a5d99a7b92
/python-contributor-workflows/run-against-node-source:
en: 4d30c74632781443
zh: 13191cb05f99dd31
diff --git a/python/development.md b/python/development.md
index b36a26b086..f863180e1b 100644
--- a/python/development.md
+++ b/python/development.md
@@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
--scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64
```
-Four scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. `sdk-minimal-in-history` reuses the persistent-shell and editor scenario with a section that changes after the first successful shell call. `minimal-in-history/prompt-history.json` pins both prompt versions, the unchanged leading prompt in later requests, appended SDK system-message events, and `request/context.systemPromptUpdate`; tool schemas stay fixed and the editor file is checked independently. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it.
+SDK scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. `sdk-minimal-in-history` reuses the persistent-shell and editor scenario with a section that changes after the first successful shell call. `minimal-in-history/prompt-history.json` pins both prompt versions, the unchanged leading prompt in later requests, appended SDK system-message events, and `request/context.systemPromptUpdate`; tool schemas stay fixed and the editor file is checked independently. `sdk-dynamic-tools` pins native tool additions and removals in `dynamic-tools/tool-history.json`, including historical header references and agreement between Python SDK results, notifications, and persisted events. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it.
The `advanced` and `restart` comparisons match native delivery qualifiers against each input's own Session generation, including SDK events and notifications. Captured and other delivery generations retain their numeric values. Each side must use one generation across its Session roles, and fresh logs must identify the current writer. Comparison never rewrites committed recordings.
diff --git a/python/development.zh.md b/python/development.zh.md
index 81e8015bbc..531c547def 100644
--- a/python/development.zh.md
+++ b/python/development.zh.md
@@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
--scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64
```
-其中四个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。`sdk-minimal-in-history` 复用持久 shell 与编辑器场景,并在首次 shell 调用成功后更改一个分段。`minimal-in-history/prompt-history.json` 固定两个提示词版本、后续请求中不变的首条提示词、追加的 SDK system-message 事件及 `request/context.systemPromptUpdate`;工具 schema 保持不变,并独立检查编辑器创建的文件。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。
+SDK 场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。`sdk-minimal-in-history` 复用持久 shell 与编辑器场景,并在首次 shell 调用成功后更改一个分段。`minimal-in-history/prompt-history.json` 固定两个提示词版本、后续请求中不变的首条提示词、追加的 SDK system-message 事件及 `request/context.systemPromptUpdate`;工具 schema 保持不变,并独立检查编辑器创建的文件。`sdk-dynamic-tools` 在 `dynamic-tools/tool-history.json` 中固定原生工具的新增与移除,包括历史 header 引用,以及 Python SDK 结果、通知与持久事件的一致性。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。
`advanced` 与 `restart` 的比较按每份输入自身的 Session 代际匹配原生 delivery 限定值,包括 SDK 事件与通知。捕获的代际及其他 delivery 代际保留数值。每一侧的 Session 角色必须使用同一个代际,新日志必须标识当前写入器。比较绝不重写已提交的记录。
diff --git a/scripts/fixtures/python-sdk-dynamic-tools.mjs b/scripts/fixtures/python-sdk-dynamic-tools.mjs
new file mode 100644
index 0000000000..e21829c33d
--- /dev/null
+++ b/scripts/fixtures/python-sdk-dynamic-tools.mjs
@@ -0,0 +1,28 @@
+/** Add a native tool after a file read and unregister it after its first execution. */
+export const name = 'python-sdk-dynamic-tools'
+export const inject = ['tools']
+
+/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned tool registry. */
+export function apply(ctx) {
+ let removeTool
+ ctx.on('tools/post-execute', async (exec, result, next) => {
+ const downstream = await next()
+ if (result.isError) return downstream
+ if (exec.name === 'read' && exec.arguments.file_path === 'dynamic-tool-task.txt') {
+ removeTool = ctx.effect(() => ctx.tools.register({
+ name: 'snapshot_ping',
+ description: 'Return pong once.',
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
+ output: {
+ schema: { type: 'string' },
+ render(_args, value) { return [{ type: 'text', text: value }] },
+ },
+ async execute() { return 'pong' },
+ }))
+ } else if (exec.name === 'snapshot_ping') {
+ removeTool?.()
+ removeTool = undefined
+ }
+ return downstream
+ })
+}
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index ffd935c1cc..4242025c2c 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -36,6 +36,8 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent shell."
MINIMAL_TEXT = "minimal agent smoke ok"
+DYNAMIC_TOOLS_PROMPT = "Read dynamic-tool-task.txt, call the newly available snapshot_ping tool once, then reply DYNAMIC_TOOLS_OK."
+DYNAMIC_TOOLS_TEXT = "DYNAMIC_TOOLS_OK"
FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools."
FS_SEARCH_TEXT = "filesystem search smoke ok"
FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK"
@@ -124,6 +126,10 @@ IN_HISTORY_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal-in-history"
)
IN_HISTORY_SNAPSHOT_FILENAMES = ("prompt-history.json",)
+DYNAMIC_TOOLS_SNAPSHOT_DIRECTORY = (
+ Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "dynamic-tools"
+)
+DYNAMIC_TOOLS_SNAPSHOT_FILENAMES = ("tool-history.json",)
RESTART_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
)
@@ -334,6 +340,17 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
minimal = minimal_tool_followup(call_id, tool_name, tool_text)
if minimal is not None:
return minimal
+ if call_id == "python-dynamic-read" and tool_name == "read":
+ if "Call snapshot_ping once." not in tool_text or result.get("is_error"):
+ raise AssertionError(f"dynamic tool scenario could not read its task: {tool_text}")
+ assert_advertised_tool(body, "snapshot_ping")
+ return tool_call_chunks("python-dynamic-ping", "snapshot_ping", {})
+ if call_id == "python-dynamic-ping" and tool_name == "snapshot_ping":
+ if tool_text != "pong" or result.get("is_error"):
+ raise AssertionError(f"dynamically added tool did not execute: {tool_text}")
+ if "snapshot_ping" in advertised_tool_names(body):
+ raise AssertionError("addition-only request retained the removed tool")
+ return text_chunks(DYNAMIC_TOOLS_TEXT)
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
if advanced is not None:
return advanced
@@ -374,6 +391,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
RESTART_SECOND_PROMPT,
PROFILE_PLUGIN_PROMPT,
AUTHORING_PROMPT,
+ DYNAMIC_TOOLS_PROMPT,
}
prompt = next(
(candidate for candidate in user_prompts if candidate in scenario_prompts),
@@ -396,6 +414,11 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
)
if prompt == RESTART_FIRST_PROMPT:
return text_chunks(RESTART_FIRST_TEXT)
+ if prompt == DYNAMIC_TOOLS_PROMPT:
+ assert_advertised_tool(body, "read")
+ if "snapshot_ping" in advertised_tool_names(body):
+ raise AssertionError("dynamic tool was registered before its enabling read")
+ return tool_call_chunks("python-dynamic-read", "read", {"file_path": "dynamic-tool-task.txt"})
if prompt == RESTART_SECOND_PROMPT:
if any(
isinstance(message, dict)
@@ -720,7 +743,7 @@ def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scenario",
- choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
+ choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
default="all",
)
parser.add_argument("--exe", type=Path)
@@ -739,10 +762,10 @@ def main() -> None:
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
if args.installed_wheel:
args.exe = assert_installed_wheel_environment()
- if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
- parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
- if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
- parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, sdk-authoring, or all")
+ if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
+ parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
+ if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
+ parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-restart, sdk-authoring, or all")
if args.exe is not None and not args.exe.is_file():
parser.error(f"runtime executable does not exist: {args.exe}")
@@ -780,6 +803,9 @@ def main() -> None:
if args.scenario in {"all", "sdk-minimal-in-history"}:
assert args.exe is not None
smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots, in_history=True)
+ if args.scenario in {"all", "sdk-dynamic-tools"}:
+ assert args.exe is not None
+ smoke_sdk_dynamic_tools(model.url, args.exe.resolve(), args.update_snapshots)
if args.scenario in {"all", "sdk-fs-search"}:
assert args.exe is not None
smoke_sdk_fs_search(model.url, args.exe.resolve())
@@ -1221,6 +1247,48 @@ def smoke_sdk_minimal(
)
+def smoke_sdk_dynamic_tools(base_url: str, executable: Path, update_snapshots: bool) -> None:
+ """Observe native tool changes through the shipped SDK profile and its durable log."""
+ from deepseek_harness import DeepSeekHarness
+
+ first_request = len(MockModelHandler.requests)
+ with tempfile.TemporaryDirectory(prefix="dsh-sdk-dynamic-tools-") as temporary:
+ root = Path(temporary).resolve()
+ dsh_home = root / "home"
+ sessions = dsh_home / "sessions"
+ task = root / "dynamic-tool-task.txt"
+ task.write_text("Call snapshot_ping once.\n", encoding="utf-8")
+ patch = write_profile_patch(root, "dynamic-tools.patch.yml", sessions, [
+ {"id": "llm-deepseek", "config": {"models": [
+ {"id": "smoke-model", "toolUpdate": "addition-only"},
+ ]}},
+ {"id": "tool-bash", "disabled": True},
+ {"id": "tool-pwsh", "disabled": True},
+ {"id": "session-title-llm", "disabled": True},
+ {"insert": [{
+ "id": "dynamic-tools",
+ "name": (Path(__file__).resolve().parent / "fixtures/python-sdk-dynamic-tools.mjs").as_uri(),
+ }]},
+ ])
+ with DeepSeekHarness(
+ provider="deepseek-official", model="smoke-model", cwd=str(root),
+ dsh_bin=str(executable), dsh_home=str(dsh_home), patches=(str(patch),),
+ env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
+ api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60,
+ ) as harness:
+ result = harness.run(DYNAMIC_TOOLS_PROMPT, session_id="dynamic-tools-smoke")
+ assert result.final_response == DYNAMIC_TOOLS_TEXT, result.final_response
+ assert task.read_text(encoding="utf-8") == "Call snapshot_ping once.\n"
+ logs = read_session_logs(sessions)
+ assert set(logs) == {result.session_id}, sorted(logs)
+ files = build_dynamic_tools_snapshot_files(
+ result, MockModelHandler.requests[first_request:], logs[result.session_id], root,
+ )
+ compare_snapshot_files(
+ files, update_snapshots, DYNAMIC_TOOLS_SNAPSHOT_DIRECTORY, DYNAMIC_TOOLS_SNAPSHOT_FILENAMES,
+ )
+
+
def smoke_sdk_fs_search(base_url: str, executable: Path) -> None:
"""Exercise real grep and glob spawns through the packaged executable."""
from deepseek_harness import DeepSeekHarness
@@ -2018,6 +2086,62 @@ def build_in_history_snapshot_files(
return {"prompt-history.json": json.dumps(evidence, indent=2, ensure_ascii=False) + "\n"}
+def build_dynamic_tools_snapshot_files(
+ result: "RunResult",
+ requests: list[dict[str, object]],
+ log: list[dict[str, object]],
+ cwd: Path,
+) -> dict[str, str]:
+ """Pin tool declarations, historical schema references, and Python SDK event delivery."""
+ selected_types = {"request/header", "request/context", "developer/message", "tool/call", "tool/result"}
+ events = [event for event in result.events if event.get("type") in selected_types]
+ subscribed = [notification.payload["event"] for notification in result.notifications
+ if notification.method == "session.event"
+ and notification.payload.get("event", {}).get("type") in selected_types]
+ assert subscribed == events, "SDK notifications differ from RunResult.events"
+ assert [event for event in log if event.get("type") in selected_types] == events, "SDK events differ from persistence"
+ headers = [event for event in events if event["type"] == "request/header"]
+ assert len(headers) == 3, headers
+ names = [[tool["name"] for tool in event["data"]["header"]["tools"]] for event in headers]
+ assert ["snapshot_ping" in tools for tools in names] == [False, True, False], names
+ assert names[1] == sorted([*names[0], "snapshot_ping"]), names
+ assert names[2] == names[0], names
+ updates = [event for event in events if event["type"] == "developer/message"]
+ assert len(updates) == 2, updates
+ assert [event["data"]["message"]["content"] for event in updates] == [
+ [{"type": "tool-addition", "toolName": "snapshot_ping"}],
+ [{"type": "tool-removal", "toolName": "snapshot_ping"}],
+ ], updates
+ assert updates[0]["data"]["headerSeq"] == headers[1]["seq"], updates
+ assert "headerSeq" not in updates[1]["data"], updates[1]
+ assert all(event.get("surfaceOp") == "append" for event in updates), updates
+ calls = [event["data"]["name"] for event in events if event["type"] == "tool/call"]
+ assert calls == ["read", "snapshot_ping"], calls
+ ping = next(event["data"]["message"] for event in events
+ if event["type"] == "tool/result" and event["data"]["message"]["toolCallId"] == "python-dynamic-ping")
+ assert ping["isError"] is False and ping["content"] == [{"type": "text", "text": "pong"}], ping
+ assert len(requests) == 3, requests
+ assert all(request["system"] == requests[0]["system"] for request in requests), "tool changes altered the prompt"
+ declarations = [next((tool for tool in request["tools"] if tool["name"] == "snapshot_ping"), None)
+ for request in requests]
+ assert declarations[0] is None and declarations[2] is None, declarations
+ assert declarations[1]["defer_loading"] is True, declarations[1]
+ changes = [[block for message in request["messages"] for block in message["content"]
+ if block.get("type") in ("tool_addition", "tool_removal")] for request in requests]
+ assert changes == [[], [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "snapshot_ping"}}], []], changes
+ assert requests[1]["messages"][:len(requests[0]["messages"])] == requests[0]["messages"], "tool addition changed earlier messages"
+ replacements = [(str(cwd), "{{cwd}}"), (result.session_id, "{{session}}")]
+ evidence = {
+ "finalResponse": result.final_response,
+ "declarations": declarations,
+ "requestToolChanges": changes,
+ "events": [event for event in events if event["type"] in {"request/header", "request/context", "developer/message"}],
+ "toolCalls": calls,
+ "pingResult": ping,
+ }
+ return {"tool-history.json": json.dumps(normalize_snapshot_value(evidence, replacements), indent=2, ensure_ascii=False) + "\n"}
+
+
def build_minimal_snapshot_files(
requests: list[dict[str, object]],
cwd: Path,
diff --git a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json
new file mode 100644
index 0000000000..50c145e724
--- /dev/null
+++ b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json
@@ -0,0 +1,248 @@
+{
+ "finalResponse": "DYNAMIC_TOOLS_OK",
+ "declarations": [
+ null,
+ {
+ "name": "snapshot_ping",
+ "description": "Return pong once.",
+ "input_schema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": false
+ },
+ "defer_loading": true
+ },
+ null
+ ],
+ "requestToolChanges": [
+ [],
+ [
+ {
+ "type": "tool_addition",
+ "tool": {
+ "type": "tool_reference",
+ "name": "snapshot_ping"
+ }
+ }
+ ],
+ []
+ ],
+ "events": [
+ {
+ "type": "request/header",
+ "seq": 11,
+ "time": 0,
+ "data": {
+ "header": {
+ "config": {
+ "provider": "deepseek-official",
+ "model": "smoke-model",
+ "maxTokens": 256000,
+ "reasoningEffort": "high"
+ },
+ "adapterDefaults": {
+ "reasoningEffort": true,
+ "maxTokens": true
+ },
+ "tools": [
+ "create_goal",
+ "edit",
+ "exit_plan_mode",
+ "get_goal",
+ "glob",
+ "grep",
+ "interrupt_agent",
+ "job_kill",
+ "job_list",
+ "job_output",
+ "list_agents",
+ "load_workspace_dependencies",
+ "read",
+ "read_image",
+ "send_message",
+ "skill",
+ "subagent",
+ "subagent_fork",
+ "todo_write",
+ "update_goal",
+ "web_fetch",
+ "web_search",
+ "workflow",
+ "write"
+ ]
+ },
+ "reason": "initial"
+ }
+ },
+ {
+ "type": "request/context",
+ "seq": 12,
+ "time": 0,
+ "data": {
+ "provider": "deepseek-official",
+ "model": "smoke-model",
+ "contextWindow": 1000000
+ }
+ },
+ {
+ "type": "request/header",
+ "seq": 20,
+ "time": 0,
+ "data": {
+ "header": {
+ "config": {
+ "provider": "deepseek-official",
+ "model": "smoke-model",
+ "maxTokens": 256000,
+ "reasoningEffort": "high"
+ },
+ "adapterDefaults": {
+ "reasoningEffort": true,
+ "maxTokens": true
+ },
+ "tools": [
+ "create_goal",
+ "edit",
+ "exit_plan_mode",
+ "get_goal",
+ "glob",
+ "grep",
+ "interrupt_agent",
+ "job_kill",
+ "job_list",
+ "job_output",
+ "list_agents",
+ "load_workspace_dependencies",
+ "read",
+ "read_image",
+ "send_message",
+ "skill",
+ "snapshot_ping",
+ "subagent",
+ "subagent_fork",
+ "todo_write",
+ "update_goal",
+ "web_fetch",
+ "web_search",
+ "workflow",
+ "write"
+ ]
+ },
+ "reason": "change"
+ }
+ },
+ {
+ "type": "developer/message",
+ "seq": 21,
+ "time": 0,
+ "data": {
+ "turn": 1,
+ "step": 2,
+ "message": {
+ "source": {
+ "kind": "tool-registry"
+ },
+ "content": [
+ {
+ "type": "tool-addition",
+ "toolName": "snapshot_ping"
+ }
+ ],
+ "role": "developer",
+ "id": "{{messageId}}"
+ },
+ "headerSeq": 20
+ },
+ "surfaceOp": "append"
+ },
+ {
+ "type": "request/header",
+ "seq": 28,
+ "time": 0,
+ "data": {
+ "header": {
+ "config": {
+ "provider": "deepseek-official",
+ "model": "smoke-model",
+ "maxTokens": 256000,
+ "reasoningEffort": "high"
+ },
+ "adapterDefaults": {
+ "reasoningEffort": true,
+ "maxTokens": true
+ },
+ "tools": [
+ "create_goal",
+ "edit",
+ "exit_plan_mode",
+ "get_goal",
+ "glob",
+ "grep",
+ "interrupt_agent",
+ "job_kill",
+ "job_list",
+ "job_output",
+ "list_agents",
+ "load_workspace_dependencies",
+ "read",
+ "read_image",
+ "send_message",
+ "skill",
+ "subagent",
+ "subagent_fork",
+ "todo_write",
+ "update_goal",
+ "web_fetch",
+ "web_search",
+ "workflow",
+ "write"
+ ]
+ },
+ "reason": "change"
+ }
+ },
+ {
+ "type": "developer/message",
+ "seq": 29,
+ "time": 0,
+ "data": {
+ "turn": 1,
+ "step": 3,
+ "message": {
+ "source": {
+ "kind": "tool-registry"
+ },
+ "content": [
+ {
+ "type": "tool-removal",
+ "toolName": "snapshot_ping"
+ }
+ ],
+ "role": "developer",
+ "id": "{{messageId}}"
+ }
+ },
+ "surfaceOp": "append"
+ }
+ ],
+ "toolCalls": [
+ "read",
+ "snapshot_ping"
+ ],
+ "pingResult": {
+ "role": "tool",
+ "source": {
+ "kind": "tool",
+ "callId": "python-dynamic-ping"
+ },
+ "toolCallId": "python-dynamic-ping",
+ "content": [
+ {
+ "type": "text",
+ "text": "pong"
+ }
+ ],
+ "isError": false,
+ "id": "{{messageId}}"
+ }
+}
diff --git a/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl b/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl
new file mode 100644
index 0000000000..f6d2438aa6
--- /dev/null
+++ b/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl
@@ -0,0 +1,29 @@
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
+{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"initial"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_task_read"},"toolCallId":"call_task_read","content":[{"type":"text","text":"{{cwd}}/task.txt\nfile\n\n1: Reply with the single word DONE after reading this file.\n\n(End of file - total 1 lines)\n"}],"isError":false,"id":"{{sessionId}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"Reply with the single word DONE after reading this file."}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"change"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":19,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":25,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"change"}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{sessionId}}"}},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
+{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
diff --git a/snapshots/sdk/dynamic-tool-updates/result.expected.json b/snapshots/sdk/dynamic-tool-updates/result.expected.json
new file mode 100644
index 0000000000..a078961bf8
--- /dev/null
+++ b/snapshots/sdk/dynamic-tool-updates/result.expected.json
@@ -0,0 +1 @@
+{"sessionId":"{{sessionId}}","finalResponse":"DONE"}
diff --git a/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl b/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl
new file mode 100644
index 0000000000..6bb2451d46
--- /dev/null
+++ b/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl
@@ -0,0 +1,31 @@
+{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"permission/preset","data":{"preset":"danger-full-access"}}
+{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
+{"type":"approval/policy","data":{"policy":"never"}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"initial"}}
+{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790049391235,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391235,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1790049391235,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
+{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_task_read"},"toolCallId":"call_task_read","content":[{"type":"text","text":"{{cwd}}/task.txt\nfile\n\n1: Reply with the single word DONE after reading this file.\n\n(End of file - total 1 lines)\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"Reply with the single word DONE after reading this file."}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"step/start","data":{"turn":1,"step":2}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"change"}}
+{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1790049391255,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391255,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790049391255,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790049391255,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
+{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}
+{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":2}}
+{"type":"step/start","data":{"turn":1,"step":3}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"change"}}
+{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:9}}"}},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790049391270,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790049391270,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790049391270,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":3}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/snapshots/sdk/dynamic-tool-updates/snapshot.yml b/snapshots/sdk/dynamic-tool-updates/snapshot.yml
new file mode 100644
index 0000000000..40bcf70847
--- /dev/null
+++ b/snapshots/sdk/dynamic-tool-updates/snapshot.yml
@@ -0,0 +1,8 @@
+version: 1
+scenario: dynamic-tool-updates
+profile: sdk
+composition: dynamic-tool-updates
+recording: authored
+header:
+ class: dynamic-tool-updates
+ changes: 2
diff --git a/snapshots/sdk/dynamic-tool-updates/workspace/task.txt b/snapshots/sdk/dynamic-tool-updates/workspace/task.txt
new file mode 100644
index 0000000000..57548edaf3
--- /dev/null
+++ b/snapshots/sdk/dynamic-tool-updates/workspace/task.txt
@@ -0,0 +1 @@
+Reply with the single word DONE after reading this file.
diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts
index 165dbe43ac..386212440a 100644
--- a/snapshots/sdk/sdk.snapshot.ts
+++ b/snapshots/sdk/sdk.snapshot.ts
@@ -125,6 +125,9 @@ interface SdkAssertions {
}
const SDK_ASSERTIONS: Readonly> = {
+ 'dynamic-tool-updates': {
+ expectedFinalResponse: 'DONE',
+ },
'tool-error-details': {
patches: [fileURLToPath(new URL('./tool-error-details/runtime.cordis.yml', import.meta.url))],
expectedFinalResponse: 'ERROR_DETAILS_OK',
@@ -842,6 +845,40 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
)
reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate')
const actualContext = contextOf(ordered, cwd)
+ if (scenario.name === 'dynamic-tool-updates') {
+ const selectedTypes = new Set(['request/header', 'request/context', 'developer/message', 'tool/call', 'tool/result'])
+ const events = results.flatMap(result => result.events).filter(event => selectedTypes.has(event.type))
+ const subscribed = notifications.flatMap(notification => {
+ const event = notificationEvent(notification)
+ return event !== undefined && typeof event.type === 'string' && selectedTypes.has(event.type) ? [event] : []
+ })
+ expect(subscribed, 'SDK notifications retain the dynamic-tool events').toEqual(events)
+ expect(records(ordered[0]!.content).filter(event => typeof event.type === 'string' && selectedTypes.has(event.type)),
+ 'SDK events agree with the durable log').toEqual(events)
+ const headers = events.filter(event => event.type === 'request/header')
+ expect(headers).toHaveLength(3)
+ const toolNames = headers.map(event => (event.data as { header: { tools: LoggedTool[] } }).header.tools.map(tool => tool.name))
+ expect(toolNames.map(names => names.includes('snapshot_ping'))).toEqual([false, true, false])
+ expect(toolNames[1]!.filter(name => !toolNames[0]!.includes(name))).toEqual(['snapshot_ping'])
+ expect(toolNames[2]).toEqual(toolNames[0])
+ const updates = events.filter(event => event.type === 'developer/message')
+ expect(updates).toMatchObject([
+ { data: { headerSeq: headers[1]!.seq, message: {
+ role: 'developer', source: { kind: 'tool-registry' },
+ content: [{ type: 'tool-addition', toolName: 'snapshot_ping' }],
+ } }, surfaceOp: 'append' },
+ { data: { message: {
+ role: 'developer', source: { kind: 'tool-registry' },
+ content: [{ type: 'tool-removal', toolName: 'snapshot_ping' }],
+ } }, surfaceOp: 'append' },
+ ])
+ expect(updates[1]!.data).not.toHaveProperty('headerSeq')
+ expect(events.filter(event => event.type === 'tool/call').map(event => event.data['name']))
+ .toEqual(['read', 'snapshot_ping'])
+ expect(events.filter(event => event.type === 'tool/result').at(-1)).toMatchObject({ data: { message: {
+ toolCallId: 'call_dynamic_ping', isError: false, content: [{ type: 'text', text: 'pong' }],
+ } } })
+ }
if (scenario.name === 'subagent-activation-limit') {
expect(ordered).toHaveLength(2)
const denied = records(ordered[0]!.content).find(record => record.type === 'tool/result'