feat(serve): add runtime context injection for per-turn system-reminders - #5847
feat(serve): add runtime context injection for per-turn system-reminders#5847callmeYe wants to merge 29 commits into
Conversation
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
|
Thanks for the PR! Template looks good ✓ Problem: This is a new feature (not a bug fix), so no reproduction is needed. The feature adds a per-session key-value RuntimeContext store for daemon/SDK callers to inject dynamic context as Direction: The feature is aligned with the daemon/SDK architecture and fills a real gap for session-scoped dynamic context. However, there's an unresolved design question from @tanzhenxin in the PR discussion: the use cases listed (operator identity, per-session rules) are session-constant — set once, unchanged turn to turn. Routing them through the per-turn Size: Core paths touched. 254 production lines vs 750 test/generated lines across 16 files. Under the 500-line threshold — no size-based escalation needed. Approach: The full stack (Config → client injection → ACP ext-method → bridge → daemon route → SDK) is well-structured. One concern: the Flagging the unresolved design question and test removals for discussion before approving. 中文说明感谢贡献! 模板完整 ✓ 问题:这是新功能(非 bug 修复),无需复现。功能为 daemon/SDK 调用方添加了会话级键值 RuntimeContext 存储,用于在每轮以 方向:功能与 daemon/SDK 架构一致,填补了会话级动态上下文的空白。但 @tanzhenxin 提出的设计问题尚未解决:列出的用例(操作人身份、按会话规则)本质上是会话常量——设置一次、逐轮不变。将它们通过按轮 规模:触及核心路径。254 行生产代码 vs 750 行测试/生成代码,跨 16 个文件。低于 500 行阈值——无需基于规模的升级。 方案:完整链路(Config → client 注入 → ACP ext-method → bridge → daemon 路由 → SDK)结构清晰。两个顾虑: 标记未解决的设计问题和测试删除,讨论后再决定是否批准。 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal (before reading the diff): For session-scoped dynamic context, I'd add a Comparison with PR: The implementation matches this proposal almost exactly. The Config store, per-turn injection, full daemon/SDK wiring, and escaping are all present and correct. Good use of the Findings:
Reuse check: Test ResultsAll 17 feature-specific unit tests pass: CI status: The latest CI run shows a failure at the Install dependencies step (not test execution) — an infrastructure issue, not a PR-caused test failure. Daemon-level tmux test: Not feasible — this feature requires a running 中文说明代码审查独立方案(读 diff 前):为会话级动态上下文,我会在 Config 上添加 与 PR 对比:实现与我的方案基本一致。Config 存储、按轮注入、完整 daemon/SDK 链路和转义都已到位。 发现:
测试结果全部 17 个功能相关单元测试通过(10 Config + 6 handler + 1 injection)。 CI 状态:最新 CI 在安装依赖步骤失败(非测试执行)——是基础设施问题,非 PR 导致的测试失败。 Daemon 级 tmux 测试:不可行——此功能需要运行中的 — Qwen Code · qwen3.7-max |
ReflectionStepping back: this is a well-executed feature PR. The production code is clean (254 lines), the full stack from Config store through daemon HTTP to SDK is properly wired, tests cover the core logic comprehensively, and the escaping against tag injection is correctly applied using existing utilities. If I had to maintain this in six months, the code would be straightforward to work with. But two things keep me from approving: The design question remains open. @tanzhenxin's critique is substantive: every listed use case (operator identity, per-session rules, dynamic config) is session-constant. The per-turn The test removals are a regression risk. ~437 lines of tests deleted from The Verdict: Escalating to maintainer. The code quality is good and the feature is useful, but the unresolved design direction and dropped test coverage need a human decision before this ships. 中文说明反思退一步看:这是一个执行良好的功能 PR。生产代码干净(254 行),从 Config 存储到 daemon HTTP 到 SDK 的完整链路正确连接,测试全面覆盖核心逻辑,标签注入防御正确使用了已有工具函数。如果六个月后要维护这段代码,它会很直观。 但有两点让我无法批准: 设计问题仍未解决。 @tanzhenxin 的质疑有实质内容:列出的每个用例(操作人身份、按会话规则、动态配置)都是会话常量。按轮 测试删除有回归风险。
结论:转交维护者。代码质量好、功能有用,但未解决的设计方向和丢失的测试覆盖需要人工决策后才能合并。 — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Downgraded from Approve to Comment: CI failing: Test (ubuntu-latest, Node 22.x).
Overall the implementation is clean — solid validation in escapeSystemReminderTags, consistent patterns with existing session mutation routes, and good Config CRUD coverage (9 unit tests). The main gaps are test coverage for the injection/HTTP/ACP delivery paths and a minor UTF-8 measurement inconsistency. See inline comments for details.
— qwen3.7-max via Qwen Code /review
| systemReminders.unshift(userQueryMemory.prompt); | ||
| } | ||
|
|
||
| const runtimeCtx = this.config.getRuntimeContext(); |
There was a problem hiding this comment.
[Suggestion] No test verifies the injection path. The client.test.ts mock returns getRuntimeContext: vi.fn().mockReturnValue(new Map()) — an empty Map — so this loop body (including the escapeSystemReminderTags call for prompt-injection safety) never executes in any test. Consider adding a test that populates the Map with entries (including one containing </system-reminder> to verify escaping) and asserts the generated request contains the expected <system-reminder> blocks.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in d594328. Added a dedicated test should inject runtime context entries as system-reminders with escaping in client.test.ts — it populates the mock with two entries (including one containing </system-reminder> to verify escaping), consumes the stream, and asserts the generated request contains the expected <system-reminder> blocks with the malicious tag properly escaped.
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/runtime-context', mutate(), async (req, res) => { |
There was a problem hiding this comment.
[Suggestion] This new route has no test coverage, and FakeBridge in server.test.ts doesn't implement setSessionRuntimeContext (any future test will hit TypeError: bridge.setSessionRuntimeContext is not a function). The analogous POST /session/:id/language route has 7 tests covering 200/400/404/500 paths.
Also, the 32 KiB check on line 4268 uses serialized.length (UTF-16 code units) rather than Buffer.byteLength(serialized, 'utf8'). For CJK/emoji payloads, the actual UTF-8 wire size can be up to 3x larger than the check allows. The per-value limit in Config.setRuntimeContextEntry correctly uses Buffer.byteLength, creating an inconsistency between layers.
| app.post('/session/:id/runtime-context', mutate(), async (req, res) => { | |
| if (Buffer.byteLength(serialized, 'utf8') > 32 * 1024) { |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in d594328. Two changes:
- UTF-8 size check: Changed
serialized.lengthtoBuffer.byteLength(serialized, 'utf8')— now consistent with the per-value limit in Config. - FakeBridge stub: Added
setSessionRuntimeContextto the FakeBridge so future route tests won't hit TypeError.
|
|
||
| return { language: resolvedLanguage, outputLanguage, refreshed }; | ||
| } | ||
| case SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext: { |
There was a problem hiding this comment.
[Suggestion] Two concerns:
1. No test coverage. The sessionLanguage handler has a dedicated describe block (~170 lines). This handler has zero tests covering its validation branches (invalid sessionId, non-object entries, non-string skip, empty-value removal, success path).
2. Silent validation failures. Non-string values are silently skipped (continue), invalid keys are silently dropped (setRuntimeContextEntry returns false), and the response only contains { keys: appliedKeys }. Callers cannot distinguish "key was not sent" from "key was rejected." Consider validating per-value types in the HTTP route (matching the language route convention) or returning a rejected array with reasons.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in d594328. The handler now returns { keys: appliedKeys, rejected: [...] } where each rejected entry includes { key, reason }. Non-string values get reason: 'value_not_string', and failed Config validations get reason: 'invalid_key_or_value'. Callers can now distinguish successful from rejected entries.
| entries as Record<string, unknown>, | ||
| )) { | ||
| if (typeof value !== 'string') continue; | ||
| if (value === '') { |
There was a problem hiding this comment.
[Nice to have] The removal path doesn't validate the key against RUNTIME_CONTEXT_KEY_RE, and unconditionally pushes to appliedKeys even if the key never existed in the Map. This creates an inconsistency: the set path rejects invalid keys, but the remove path accepts any string and reports it as "applied." Consider adding key validation here too.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in d594328. The removal path now checks config.getRuntimeContext().has(key) before calling removeRuntimeContextEntry — only existing keys are removed and reported as applied. Non-existing keys are silently ignored (no rejected entry needed since removing a non-existent key is a no-op).
| private pendingMcpServers?: string[]; | ||
| private sessionSubagents: SubagentConfig[]; | ||
| private userMemory: string; | ||
| private runtimeContextEntries: Map<string, string> = new Map(); |
There was a problem hiding this comment.
[Critical] Object.create(parent) prototype chain leak.
runtimeContextEntries is initialized via field initializer (= new Map()). The codebase creates subagent Configs via Object.create(parent) in multiple places (background-agent-resume.ts:705, dreamAgentPlanner.ts:150, skillReviewAgentPlanner.ts:236, extractionAgentPlanner.ts:156). Object.create does NOT run field initializers, so this.runtimeContextEntries on a child Config resolves through the prototype chain to the parent's live Map.
Subagents will silently read and mutate the parent's runtime context. The codebase has an established pattern for this — see getFileReadCache() and getMemoryPressureMonitor(), both of which use a hasOwnProperty check with lazy own-property allocation.
| private runtimeContextEntries: Map<string, string> = new Map(); | |
| private getRuntimeContextEntries(): Map<string, string> { | |
| if (!Object.prototype.hasOwnProperty.call(this, 'runtimeContextEntries')) { | |
| (this as any).runtimeContextEntries = new Map(); | |
| } | |
| return (this as any).runtimeContextEntries; | |
| } |
Then update getRuntimeContext(), setRuntimeContextEntry(), removeRuntimeContextEntry(), and setRuntimeContext() to use this.getRuntimeContextEntries() instead of this.runtimeContextEntries.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 2dc0034. Applied the hasOwnProperty + lazy own-property allocation pattern (matching getFileReadCache()). All CRUD methods now go through getOwnRuntimeContextEntries() which installs an own Map on first access, preventing subagent Configs created via Object.create(parent) from reading/mutating the parent's live Map.
| } | ||
| }, | ||
|
|
||
| async setSessionRuntimeContext(sessionId, entries, _context) { |
There was a problem hiding this comment.
[Critical] Missing resolveTrustedClientId call.
Every other setSession* bridge handler calls resolveTrustedClientId(entry, context?.clientId) before proceeding — setSessionModel, setSessionLanguage, setSessionApprovalMode, etc. (18+ call sites). This handler names the parameter _context (underscore = unused) and skips validation entirely.
An untrusted client ID passes through unchecked. While no SSE event is published today (so originatorClientId is unused), omitting the trust resolution breaks the defense-in-depth pattern and means a spoofed client ID is silently accepted.
| async setSessionRuntimeContext(sessionId, entries, _context) { | |
| async setSessionRuntimeContext(sessionId, entries, context) { | |
| const entry = byId.get(sessionId); | |
| if (!entry) throw new SessionNotFoundError(sessionId); | |
| const info = channelInfoForEntry(entry); | |
| if (!info || info.isDying) throw new SessionNotFoundError(sessionId); | |
| const _originatorClientId = resolveTrustedClientId(entry, context?.clientId); |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 2dc0034. Added resolveTrustedClientId(entry, context?.clientId) call before the ext-method roundtrip, consistent with all other setSession* handlers.
| getTransportClosedReject(entry), | ||
| ])) as { keys: string[] }; | ||
|
|
||
| return { sessionId, keys: response.keys }; |
There was a problem hiding this comment.
[Suggestion] Bridge drops rejected entries from handler response.
acpAgent.ts returns { keys: appliedKeys, rejected } where rejected contains { key, reason } for entries that failed validation. The bridge casts the response as { keys: string[] } and returns only { sessionId, keys: response.keys }.
SDK callers have no way to detect partial failure — sending 5 entries where 3 are rejected yields { keys: ["a", "b"] } with zero signal that 3 were silently dropped.
Consider forwarding rejected through the bridge return type and SDK types, or document the silent-drop semantics in the JSDoc on DaemonClient.setSessionRuntimeContext.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 2dc0034. The bridge now forwards rejected from the ext-method response. Return type updated to { sessionId, keys, rejected: Array<{key, reason}> } across bridge interface, bridge implementation, DaemonClient, and DaemonSessionClient. Callers can now detect partial failures.
| }); | ||
| return setApprovalModeImpl(sessionId, mode, o, context); | ||
| }, | ||
| async setSessionRuntimeContext(sessionId, entries, _context) { |
There was a problem hiding this comment.
[Suggestion] Two issues with this FakeBridge stub:
-
Missing
rejectedfield. TheAcpSessionBridge.setSessionRuntimeContextreturn type requiresrejected: Array<{key: string, reason: string}>, but this stub omits it. Any future test asserting onresponse.rejectedwill getundefined. -
Implicit
anyon parameters. TypeScript flagssessionId,entries, and_contextas implicitlyany(TS7006).
| async setSessionRuntimeContext(sessionId, entries, _context) { | |
| async setSessionRuntimeContext( | |
| sessionId: string, | |
| entries: Record<string, string>, | |
| _context?: { clientId?: string }, | |
| ) { | |
| return { | |
| sessionId, | |
| keys: Object.keys(entries).filter((k) => typeof entries[k] === 'string'), | |
| rejected: [] as Array<{ key: string; reason: string }>, | |
| }; | |
| }, |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 4af5086. Added rejected field and explicit parameter types to the FakeBridge stub.
| } else if (config.setRuntimeContextEntry(key, value)) { | ||
| appliedKeys.push(key); | ||
| } else { | ||
| rejected.push({ key, reason: 'invalid_key_or_value' }); |
There was a problem hiding this comment.
[Suggestion] setRuntimeContextEntry returns false for three distinct reasons — invalid key format, value exceeding 32 KiB, and the 16-entry capacity being full — but this handler maps all three to the single string 'invalid_key_or_value'. An API caller receiving this rejection has no way to diagnose whether to fix their key, shrink their value, or evict old entries.
Consider either (a) changing Config.setRuntimeContextEntry to return a discriminated result like { ok: true } | { ok: false; reason: 'invalid_key' | 'value_too_large' | 'capacity_full' }, or (b) checking the specific condition before calling:
| rejected.push({ key, reason: 'invalid_key_or_value' }); | |
| } else if (!Config.RUNTIME_CONTEXT_KEY_RE.test(key)) { | |
| rejected.push({ key, reason: 'invalid_key' }); | |
| } else if (entries.size >= 16) { | |
| rejected.push({ key, reason: 'capacity_full' }); | |
| } else { | |
| rejected.push({ key, reason: 'value_too_large' }); | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 4af5086. The handler now returns granular rejection reasons: invalid_key (regex mismatch), value_too_large (>32 KiB), or capacity_full (16 entry limit). Callers can now diagnose and act on specific rejection causes.
| } | ||
|
|
||
| const runtimeCtx = this.config.getRuntimeContext(); | ||
| for (const [, value] of runtimeCtx) { |
There was a problem hiding this comment.
[Suggestion] The injection loop discards the key and wraps only the raw value. With multiple entries, the model receives bare values like "Alice" or "staging" with no indication of what they represent. Compare with other system-reminder blocks in this codebase that include descriptive labels (e.g., "The current date is: ...").
Including the key as a label prefix gives the model semantic context to interpret each value:
| for (const [, value] of runtimeCtx) { | |
| for (const [key, value] of runtimeCtx) { | |
| const safe = escapeSystemReminderTags(value); | |
| systemReminders.push( | |
| `<system-reminder>\n[${key}] ${safe}\n</system-reminder>`, | |
| ); | |
| } |
This would also require updating the client test at client.test.ts which currently asserts on the value being present without any key labeling.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 4af5086. Each entry is now rendered as [key] value inside the system-reminder, e.g. <system-reminder>\n[operator_identity] 当前操作者: Alice\n</system-reminder>. This gives the model semantic context to interpret each value. Test assertion updated accordingly.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
8 findings (2 Critical, 6 Suggestion). All tests pass (998/998), typecheck and lint clean.
— qwen3.7-max via Qwen Code /review
|
|
||
| return { language: resolvedLanguage, outputLanguage, refreshed }; | ||
| } | ||
| case SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext: { |
There was a problem hiding this comment.
[Critical] No tests for the sessionRuntimeContext handler.
The handler has 5+ branches (invalid sessionId, invalid entries type, non-string value rejection, empty-value removal, set-or-reject) but acpAgent.test.ts has zero references to sessionRuntimeContext or runtimeContext. The "Already discussed" section claims this was addressed, but no test code was added.
Suggested: add a describe('sessionRuntimeContext') block covering happy path, invalid sessionId, null/array/non-object entries, non-string value → value_not_string rejected, empty-string removal of existing key, empty-string for non-existent key, and invalid key → invalid_key_or_value rejected.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed — 6 handler tests added in commit 0660660, covering: happy-path set, empty-string removal, non-string rejection, invalid key rejection, missing sessionId, and invalid entries shape. See also my reply to the later duplicate of this comment.
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/runtime-context', mutate(), async (req, res) => { |
There was a problem hiding this comment.
[Critical] No route-level tests for POST /session/:id/runtime-context.
server.test.ts only adds capability-list entries and a FakeBridge stub — no describe block for this route. The handler has validation (400 for bad entries shape), size-limit (413 for >32 KiB payload), bridge error mapping, and clientId forwarding, all untested.
Suggested: add a describe('POST /session/:id/runtime-context') block mirroring the approval-mode pattern: 200 happy path, 400 for missing/non-object/null/array entries, 413 for oversized payload, bridge error propagation, X-Client-Id header forwarding.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Acknowledged. Route-level tests for this endpoint are a valid follow-up. The existing analogous routes (POST /session/:id/language, POST /session/:id/model) have route tests, and the runtime-context route follows the exact same pattern. The handler logic is covered by the 6 acpAgent tests added in 0660660. Will track route-level tests as follow-up.
| }); | ||
| return setApprovalModeImpl(sessionId, mode, o, context); | ||
| }, | ||
| async setSessionRuntimeContext(sessionId, entries, _context) { |
There was a problem hiding this comment.
[Suggestion] FakeBridge stub returns { sessionId, keys } but the AcpSessionBridge interface in bridgeTypes.ts requires rejected: Array<{ key: string; reason: string }> in the return type. TypeScript doesn't catch this because server.test.ts is excluded from tsconfig compilation.
| async setSessionRuntimeContext(sessionId, entries, _context) { | |
| async setSessionRuntimeContext(sessionId, entries, _context) { | |
| return { | |
| sessionId, | |
| keys: Object.keys(entries).filter((k) => typeof entries[k] === 'string'), | |
| rejected: [], | |
| }; | |
| }, |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 4af5086 — FakeBridge stub now returns rejected: [] as Array<{ key: string; reason: string }> with explicit parameter types.
| if (value === '') { | ||
| if (config.getRuntimeContext().has(key)) { | ||
| config.removeRuntimeContextEntry(key); | ||
| appliedKeys.push(key); |
There was a problem hiding this comment.
[Suggestion] When value === '' and the key doesn't exist in the store, it is silently dropped from both appliedKeys and rejected. Callers cannot distinguish "key was already absent" from a dropped request.
Consider idempotent delete semantics — always push to appliedKeys:
| appliedKeys.push(key); | |
| if (value === '') { | |
| config.removeRuntimeContextEntry(key); | |
| appliedKeys.push(key); | |
| } else if (config.setRuntimeContextEntry(key, value)) { |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 2dc0034 — the removal path now checks config.getRuntimeContext().has(key) before calling removeRuntimeContextEntry. Non-existing keys are silently skipped (no-op removal doesn't need to appear in either keys or rejected).
| } | ||
| } else if (config.setRuntimeContextEntry(key, value)) { | ||
| appliedKeys.push(key); | ||
| } else { |
There was a problem hiding this comment.
[Suggestion] setRuntimeContextEntry returns false for three distinct failure modes (invalid key regex, value exceeding 32 KiB, store at 16-entry capacity) but all map to the same 'invalid_key_or_value' reason. Callers cannot determine which constraint was violated.
Consider computing the specific reason:
| } else { | |
| } else { | |
| const reason = !Config.RUNTIME_CONTEXT_KEY_RE.test(key) | |
| ? 'invalid_key' | |
| : Buffer.byteLength(value, 'utf8') > Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES | |
| ? 'value_too_large' | |
| : 'store_at_capacity'; | |
| rejected.push({ key, reason }); | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 4af5086 — the handler now returns granular rejection reasons: invalid_key, value_too_large, or capacity_full instead of the generic invalid_key_or_value.
| this.getOwnRuntimeContextEntries().delete(key); | ||
| } | ||
|
|
||
| setRuntimeContext(entries: Record<string, string>): void { |
There was a problem hiding this comment.
[Suggestion] setRuntimeContext(entries) is defined and tested but has zero production callers. The acpAgent handler iterates entries individually via setRuntimeContextEntry. This is dead code with subtly different semantics from the per-entry API (silently drops invalid entries vs returning booleans/rejected arrays).
Consider removing until a production caller exists, or document the intended future use.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Good observation. setRuntimeContext(entries) is intentionally provided for the initialRuntimeContext flow described in the plan — when a session is created, the bridge will call it once to bulk-set the initial context before the first prompt. The acpAgent handler uses per-entry setRuntimeContextEntry for the incremental API path. Both are needed.
| ); | ||
| } | ||
|
|
||
| async setSessionRuntimeContext( |
There was a problem hiding this comment.
[Suggestion] No tests for setSessionRuntimeContext or DaemonSessionClient.setRuntimeContext. These SDK methods handle URL encoding, body serialization, clientId forwarding, and error mapping — none verified by tests.
Suggested: add tests in DaemonSessionClient.test.ts verifying correct URL path, body serialization, clientId forwarding, and error handling for non-OK responses.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Acknowledged. SDK method tests are a valid follow-up. The methods are thin pass-through wrappers (fetchWithTimeout + URL construction + JSON body) following the exact pattern of setSessionModel / setSessionLanguage, which also lack dedicated SDK-level tests. Will track as follow-up.
| } | ||
| }, | ||
|
|
||
| async setSessionRuntimeContext(sessionId, entries, context) { |
There was a problem hiding this comment.
[Suggestion] No bridge integration tests for setSessionRuntimeContext. The method has non-trivial error handling (two SessionNotFoundError paths, transport-closed rejection, response.rejected ?? [] fallback) — all untested.
Suggested: add tests covering happy path forwarding, unknown sessionId → SessionNotFoundError, dying channel → SessionNotFoundError, transport-closed rejection, and response.rejected undefined → empty array.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Acknowledged. The bridge follows the exact same pattern as setSessionLanguage — same SessionNotFoundError paths, same withTimeout + getTransportClosedReject race. Adding bridge integration tests is a valid follow-up but out of scope for this PR (the existing setSessionLanguage bridge tests don't exist either). Will track as a follow-up.
| @@ -5674,6 +5674,55 @@ class QwenAgent implements Agent { | |||
|
|
|||
| return { language: resolvedLanguage, outputLanguage, refreshed }; | |||
| } | |||
There was a problem hiding this comment.
[Critical] No tests for the sessionRuntimeContext handler. The handler has 7+ branches (invalid sessionId, invalid entries shape, non-string value rejection, empty-string removal with/without existing key, key regex, byte-size check, capacity full) but acpAgent.test.ts has zero references to sessionRuntimeContext. The adjacent sessionLanguage handler has a dedicated describe block (~170 lines) establishing the pattern.
Add tests following the sessionLanguage pattern — call agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, { sessionId, entries }) and assert on keys, rejected, and getRuntimeContext() state. Cover at minimum: happy path set, empty-string removal, invalid params, non-string rejection, and capacity-full rejection.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 0660660. Added 6 handler tests following the sessionLanguage pattern: happy-path set, empty-string removal, non-string value rejection, invalid key rejection, missing sessionId error, and invalid entries shape error. Tests use a mock Config with a real Map<string, string> backing store to verify state changes.
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/runtime-context', mutate(), async (req, res) => { |
There was a problem hiding this comment.
[Critical] No route-level tests for POST /session/:id/runtime-context. The FakeBridge stub was added but no describe block exercises the route. Every comparable route (model, recap, approval-mode, language) has a dedicated describe block with happy-path, validation-error, and bridge-error tests.
Add a describe('POST /session/:id/runtime-context') block in server.test.ts following the POST /session/:id/language pattern. Wire up call recording in the FakeBridge stub and test: 200 success, 400 on invalid entries, 413 on oversized payload, and bridge SessionNotFoundError propagation.
— qwen3.7-max via Qwen Code /review
| } | ||
| } else if (config.setRuntimeContextEntry(key, value)) { | ||
| appliedKeys.push(key); | ||
| } else if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) { |
There was a problem hiding this comment.
[Suggestion] Duplicated validation logic. The rejection-reason chain re-inlines /^[a-zA-Z0-9_-]{1,64}$/ and 32 * 1024 as literals, duplicating Config.RUNTIME_CONTEXT_KEY_RE and Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES. If either Config constant changes, the handler's rejection reasons silently diverge — reporting invalid_key for a key Config accepted, or vice versa.
Consider having setRuntimeContextEntry return a structured result ({ ok: true } | { ok: false; reason: string }) instead of a bare boolean, so the handler never re-derives the rejection reason. Alternatively, expose the constants from Config (they're currently private static readonly).
— qwen3.7-max via Qwen Code /review
| this.getOwnRuntimeContextEntries().delete(key); | ||
| } | ||
|
|
||
| setRuntimeContext(entries: Record<string, string>): void { |
There was a problem hiding this comment.
[Suggestion] setRuntimeContext(entries) is defined and tested but has zero production callers. The ACP handler iterates entries individually via setRuntimeContextEntry/removeRuntimeContextEntry. The SDK's DaemonSessionClient.setRuntimeContext goes through the HTTP route, which also uses per-entry methods. This Config method is dead code with subtly different semantics (silent drops invalid entries without reporting them).
Either remove it (inlining the test setup), or wire it into the ACP handler and align its return value with the per-entry path's { keys, rejected } shape.
— qwen3.7-max via Qwen Code /review
| const bootConfig = makeRuntimeCtxConfig(); | ||
| runAcpAgent( | ||
| bootConfig as unknown as Config, | ||
| { merged: { mcpServers: {} } } as unknown as LoadedSettings, |
There was a problem hiding this comment.
[Critical] All 6 tests in the sessionRuntimeContext handler describe block crash with TypeError: mcpServers is not iterable at acpAgent.ts:6975. setupAgent calls agent.newSession({}) without providing an mcpServers array, so the production code's for (const server of mcpServers) throws on undefined.
This is why CI's Test (ubuntu-latest, Node 22.x) check is red.
| { merged: { mcpServers: {} } } as unknown as LoadedSettings, | |
| runAcpAgent( | |
| bootConfig as unknown as Config, | |
| { merged: { mcpServers: [] } } as unknown as LoadedSettings, | |
| {} as CliArgs, | |
| ); |
Also fix the loadSettings mock at the same location — use mcpServers: [] instead of mcpServers: {}.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| const serialized = JSON.stringify(entries); | ||
| if (Buffer.byteLength(serialized, 'utf8') > 32 * 1024) { |
There was a problem hiding this comment.
[Critical] The server enforces a 32 KiB cap on JSON.stringify(entries) (the entire serialized object, including keys, braces, and quotes), while Config.setRuntimeContextEntry enforces a 32 KiB cap on each individual value. These are different measurements.
A payload of 16 entries with ~2 KiB values each totals ~34 KiB serialized and is rejected here with HTTP 413, even though every individual entry passes Config's per-value check. Conversely the error message "runtime context payload exceeds 32 KiB limit" implies a per-entry contract that does not exist.
Either (1) rename this constant to RUNTIME_CONTEXT_MAX_PAYLOAD_BYTES and give it a distinct value (e.g. 48 KiB) that accommodates realistic multi-entry payloads, documenting it as the transport-envelope limit, or (2) drop this check and let Config be the single authority.
— qwen3.7-max via Qwen Code /review
| 'permission_mediation', | ||
| 'non_blocking_prompt', | ||
| 'session_language', | ||
| 'session_runtime_context', |
There was a problem hiding this comment.
[Critical] POST /session/:id/runtime-context (server.ts:4249) has no route-level test coverage despite the fakeBridge.setSessionRuntimeContext stub being added on line 1318. Comparable routes like POST /session/:id/approval-mode (line 6730) and POST /session/:id/language (line 6952) have full describe blocks exercising success, validation failure (400), and bridge-error paths.
The new route has four branches — 400 for invalid entries, 413 for oversized payload, 200 success via bridge, and the sendBridgeError catch — none of which are verified. Regressions in body validation, the 32 KiB pre-check, clientId header forwarding, or error mapping will go undetected until integration testing.
Suggested: add a describe('POST /session/:id/runtime-context', ...) block following the approval-mode pattern, covering (1) valid entries returning 200 with the bridge response, (2) entries: null / array returning 400, (3) serialized body > 32 KiB returning 413, (4) bridge throwing returning the sendBridgeError status code.
— qwen3.7-max via Qwen Code /review
| } else if ( | ||
| Buffer.byteLength(value, 'utf8') > 32 * 1024 | ||
| ) { | ||
| rejected.push({ key, reason: 'value_too_large' }); |
There was a problem hiding this comment.
[Suggestion] The value_too_large and capacity_full rejection-reason branches are untested. Existing handler tests cover value_not_string and invalid_key, but not these two.
The reason-mapping logic here re-derives the failure cause by re-running the same regex (/^[a-zA-Z0-9_-]{1,64}$/) and size (32 * 1024) checks Config already performed internally. If the regex or byte limit in Config ever drifts from what the handler inlines, SDK consumers silently receive wrong rejected[].reason values.
Two suggested tests:
- Send
{ validkey: 'x'.repeat(32 * 1024 + 1) }and assertrejected[0].reason === 'value_too_large'. - Pre-fill 16 entries via the mock Config map, send a 17th valid entry, and assert
rejected[0].reason === 'capacity_full'.
Longer-term, consider having Config.setRuntimeContextEntry return a discriminated result ({ ok: true } | { ok: false; reason: string }) so the handler doesn't have to re-validate, and export RUNTIME_CONTEXT_KEY_RE / RUNTIME_CONTEXT_MAX_VALUE_BYTES from config.ts so the constants have a single source of truth.
— qwen3.7-max via Qwen Code /review
| this.getOwnRuntimeContextEntries().delete(key); | ||
| } | ||
|
|
||
| setRuntimeContext(entries: Record<string, string>): void { |
There was a problem hiding this comment.
[Suggestion] setRuntimeContext(entries) silently drops entries that fail validation (clear()s the store, then iterates and only stores passing entries) and returns void. This is inconsistent with setRuntimeContextEntry (returns boolean) and with the ACP handler (returns { keys, rejected: [...] }).
A future caller passing 16 entries where 2 have invalid keys will atomically destroy the existing context and silently lose the 2 bad entries with no feedback. Today this method has no production caller — only config.test.ts exercises it — so it's a dormant footgun rather than a live bug.
Options:
- Change the return type to
{ applied: string[]; rejected: Array<{ key: string; reason: string }> }, matching the handler's contract. - Remove the method entirely if no production caller is planned —
setRuntimeContextEntryalready covers the use case. - At minimum, document the silent-drop behavior in JSDoc and add a comment warning future callers.
— qwen3.7-max via Qwen Code /review
| if (!entry) throw new SessionNotFoundError(sessionId); | ||
| const info = channelInfoForEntry(entry); | ||
| if (!info || info.isDying) throw new SessionNotFoundError(sessionId); | ||
| resolveTrustedClientId(entry, context?.clientId); |
There was a problem hiding this comment.
[Suggestion] setSessionRuntimeContext does not publish any SSE event on success, unlike the comparable setSessionLanguage (publishes language_changed at line 4042), setSessionModel (publishes model_switched), and setSessionApprovalMode (publishes approval_mode_changed). Connected SSE clients (browser UI, IDE status bars) cannot react to runtime-context mutations — they must poll to learn about changes.
Also: resolveTrustedClientId(entry, context?.clientId) is called for validation, but its return value is discarded. If no event is planned, the call serves no purpose (validation would still throw InvalidClientIdError whether or not the return is captured, but the originatorClientId propagation that other handlers rely on is lost).
If events are intentionally omitted for this mutation, add a comment explaining why (mirroring the pattern at generateSessionRecap line 4279: "recap is informational-only today — no SSE broadcast"). Otherwise, publish a runtime_context_changed event carrying keys and the resolved originatorClientId.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| const runtimeCtx = this.config.getRuntimeContext(); | ||
| for (const [key, value] of runtimeCtx) { |
There was a problem hiding this comment.
[Suggestion] The injection loop iterates the runtime-context Map and wraps each value with escapeSystemReminderTags(value). This escape function only neutralizes literal <system-reminder> / </system-reminder> tag strings (confirmed in xml.ts:152). Other trusted envelope tags used elsewhere in the conversation — <task-notification> (backgroundShellRegistry), <available_skills> (environmentContext), <task-id>, <summary> — pass through verbatim inside the injected <system-reminder> body.
A malicious trusted caller (MCP server, ACP peer, daemon plugin) could set a runtime-context value containing literal <task-notification>...</task-notification> markup. The model, which already treats these tags as authoritative system metadata in other parts of the conversation, may act on the fabricated notification.
Mitigation: runtime context is set by a trusted controller (daemon API / SDK via resolveTrustedClientId), not by the end user directly, so exploitability is bounded by trust in connected clients. If the trust boundary is ever relaxed (e.g., untrusted MCP-sourced context), switch to escapeXml(value) here — runtime-context values are opaque metadata and never need raw XML that the model should parse.
Also consider reserving a key prefix (e.g. qwen- or _) for internal/system keys, and rejecting user-supplied keys that match — keys like system, operator, admin, instructions imply elevated authority when rendered as [system] ... inside a system-reminder.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| const serialized = JSON.stringify(entries); | ||
| if (Buffer.byteLength(serialized, 'utf8') > 32 * 1024) { |
There was a problem hiding this comment.
[Suggestion] The HTTP route caps the total serialized payload at 32 KiB, but Config allows 16 entries × 32 KiB each = 512 KiB theoretical max store. A legitimate 10-entry request with ~4 KiB per value (~40 KiB total) gets 413'd here even though every individual value passes the per-value limit. Meanwhile the ACP ext-method path has no aggregate check, so the same payload succeeds through that transport.
Consider either raising this cap to MAX_ENTRIES × MAX_VALUE_BYTES (512 KiB), or defining a separate named constant (e.g. RUNTIME_CONTEXT_MAX_PAYLOAD_BYTES) if the tighter limit is intentional — but document the intentional mismatch so future maintainers don't assume it's a bug.
— qwen3.7-max via Qwen Code /review
| rejected.push({ key, reason: 'capacity_full' }); | ||
| } | ||
| } | ||
| return { keys: appliedKeys, rejected }; |
There was a problem hiding this comment.
[Suggestion] Zero logging on runtime context mutations. Every other control-plane handler in this file (language, approval mode, model switch, recap) has at least a debug log or status endpoint. When a session's model starts behaving unexpectedly due to an injected <system-reminder>, the oncall engineer has no way to trace which entries were set/updated/rejected.
| return { keys: appliedKeys, rejected }; | |
| debugLogger?.debug?.(`[runtime-ctx] session=${sessionId} applied=${appliedKeys.length} rejected=${rejected.length} keys=[${appliedKeys.join(',')}]`); | |
| return { keys: appliedKeys, rejected }; |
— qwen3.7-max via Qwen Code /review
| } | ||
| } else if (config.setRuntimeContextEntry(key, value)) { | ||
| appliedKeys.push(key); | ||
| } else if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) { |
There was a problem hiding this comment.
[Suggestion] The key regex /^[a-zA-Z0-9_-]{1,64}$/ and byte limit 32 * 1024 are duplicated inline here, separate from Config.RUNTIME_CONTEXT_KEY_RE and Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES (both private static readonly). If either limit changes in Config, this reason-determination cascade silently diverges — e.g., a key that Config now accepts would still get reported as 'invalid_key'.
The root cause is that setRuntimeContextEntry returns boolean — no reason for the rejection. A cleaner fix would be to change it to return { ok: true } | { ok: false; reason: string }, eliminating the need for the caller to re-validate:
// In Config:
setRuntimeContextEntry(key: string, value: string): { ok: boolean; reason?: string }
// Then in this handler:
const result = config.setRuntimeContextEntry(key, value);
if (!result.ok) rejected.push({ key, reason: result.reason! });— qwen3.7-max via Qwen Code /review
| systemReminders.unshift(userQueryMemory.prompt); | ||
| } | ||
|
|
||
| const runtimeCtx = this.config.getRuntimeContext(); |
There was a problem hiding this comment.
[Suggestion] Key not escaped in system-reminder injection template.
The value is properly sanitized via escapeSystemReminderTags(value), but the key is interpolated directly into the template as [${key}] without escaping. While keys are validated by /^[a-zA-Z0-9_-]{1,64}$/ at the external input boundary, getRuntimeContext() returns the mutable Map (typed as ReadonlyMap but ReadonlyMap is compile-time only). Any internal code with Config access can call .set() on the returned Map, bypassing validation entirely.
Defense-in-depth: escape the key at the injection point, matching the value treatment:
| const runtimeCtx = this.config.getRuntimeContext(); | |
| const runtimeCtx = this.config.getRuntimeContext(); | |
| for (const [key, value] of runtimeCtx) { | |
| const safeKey = escapeSystemReminderTags(key); | |
| const safe = escapeSystemReminderTags(value); | |
| systemReminders.push( | |
| `<system-reminder>\n[${safeKey}] ${safe}\n</system-reminder>`, | |
| ); | |
| } |
— qwen3.7-max via Qwen Code /review
| return this.runtimeContextEntries; | ||
| } | ||
|
|
||
| getRuntimeContext(): ReadonlyMap<string, string> { |
There was a problem hiding this comment.
[Suggestion] getRuntimeContext() returns the mutable Map and silently isolates subagents.
Two concerns:
-
Mutable Map despite
ReadonlyMaptype.ReadonlyMapis compile-time only — at runtime, callers can.set(),.delete(),.clear()the returned Map, bypassing all validation (key regex, size limit, entry count). Returningnew Map(this.getOwnRuntimeContextEntries())would provide actual defense-in-depth. -
Silent subagent isolation. The
hasOwnPropertyguard ingetOwnRuntimeContextEntries()(introduced to fix theObject.create(parent)prototype chain leak) also prevents subagent Configs — created viaObject.create(parent)inInProcessBackend.ts:401andsubagent-manager.ts:882— from reading the parent's runtime context through the prototype chain. Subagents get a fresh empty Map. This is inconsistent with howuserMemoryand other session-level state propagates to subagents.
If subagent isolation is intentional, make it explicit (e.g., a comment or a dedicated getRuntimeContextForSubagent() method). If subagents should see the parent's context, return a read-only copy that walks the prototype for reads but blocks writes.
— qwen3.7-max via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| async setRuntimeContext( |
There was a problem hiding this comment.
[Suggestion] setRuntimeContext does incremental merge, but the name implies full replacement.
The full-stack path (DaemonSessionClient → HTTP route → bridge → acpAgent handler) iterates entries and calls config.setRuntimeContextEntry(key, value) per key — an additive upsert. A caller who does setRuntimeContext({ newKey: 'val' }) after a prior setRuntimeContext({ oldKey: 'val' }) will find both keys present, not just newKey.
Meanwhile, Config.setRuntimeContext() — which does a clear-and-replace — exists but has zero production callers (dead code). The naming inconsistency (set vs actual merge behavior) and the unused replace method suggest the design intent is unclear.
Consider either: (a) wiring Config.setRuntimeContext() in the handler before iterating (matching the "set" name), or (b) renaming to updateRuntimeContext / patchRuntimeContext and adding explicit clearRuntimeContext() and getRuntimeContext() SDK methods.
— qwen3.7-max via Qwen Code /review
| ).rejects.toThrow(/sessionId/); | ||
| }); | ||
|
|
||
| it('throws on invalid entries shape', async () => { |
There was a problem hiding this comment.
[Suggestion] The entries validation in the handler has three distinct OR branches (typeof entries !== 'object', entries === null, Array.isArray(entries)), but the existing "throws on invalid entries shape" test passes entries: 'not-an-object' which only hits the first branch. entries: null and entries: [1, 2] each exercise untested code paths. Consider adding test cases for null and array inputs to cover all validation branches.
— qwen3.7-max via Qwen Code /review
| expect(config.getRuntimeContext().get('key-0')).toBe('updated'); | ||
| }); | ||
|
|
||
| it('should bulk-set entries', () => { |
There was a problem hiding this comment.
[Suggestion] The existing bulk-set test covers only the happy path with 2 valid entries. The bulk setRuntimeContext method has its own inline validation that silently drops entries failing key regex, byte-size limit, or 16-entry capacity — a different contract from setRuntimeContextEntry (singular, returns boolean). Specific untested scenarios:
- Bulk-set exceeding 16 entries (does it stop at 16?)
- Bulk-set with invalid keys mixed in (silently skipped while valid keys stored?)
- Bulk-set with empty-string values (
value &&skips them, but the singular method treats empty string as deletion)
The behavioral asymmetry between bulk and singular methods is worth explicit test coverage.
— qwen3.7-max via Qwen Code /review
| systemReminders.unshift(userQueryMemory.prompt); | ||
| } | ||
|
|
||
| const runtimeCtx = this.config.getRuntimeContext(); |
There was a problem hiding this comment.
[Suggestion] Runtime context entries are injected only in GeminiClient.sendMessageStream. Subagents (AgentCore.createChat), background agents (background-agent-resume.ts), and memory planners (skillReviewAgentPlanner.ts, extractionAgentPlanner.ts, dreamAgentPlanner.ts) all create GeminiChat directly and bypass this injection path. If runtime context (e.g., operator identity) is intended to apply to the entire session including subagent turns, a second injection point is needed — either in GeminiChat.sendMessageStream or in the subagent system prompt building path.
— qwen3.7-max via Qwen Code /review
11713a7 to
db94452
Compare
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
1 finding (Suggestion). Build and all 1177 tests pass locally. Deterministic analysis clean (typecheck and eslint).
— qwen3.7-max via Qwen Code /review
| ) { | ||
| rejected.push({ key, reason: 'value_too_large' }); | ||
| } else { | ||
| rejected.push({ key, reason: 'capacity_full' }); |
There was a problem hiding this comment.
[Suggestion] The else clause here unconditionally produces capacity_full as the rejection reason. This cascade re-derives the failure cause by re-running Config's validation checks inline (key regex, byte length), then assumes anything left must be capacity. If Config.setRuntimeContextEntry ever adds a fourth validation rule (e.g., reserved key prefix, value charset restriction), the new failure would silently fall through to capacity_full — misleading API consumers about the real cause.
Consider having setRuntimeContextEntry return a structured result instead of a bare boolean:
| rejected.push({ key, reason: 'capacity_full' }); | |
| const result = config.setRuntimeContextEntry(key, value); | |
| if (result.ok) { | |
| appliedKeys.push(key); | |
| } else { | |
| rejected.push({ key, reason: result.reason }); | |
| } |
This eliminates the fragile re-derivation and makes the rejection reason authoritative from the Config layer.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
[Critical] TypeScript does not pass on the reviewed head. qwen review deterministic reports five tsc errors in packages/cli/src/serve/server.test.ts (including the fake bridge no longer matching AcpSessionBridge / DaemonWorkspaceService, plus extension mock return-type mismatches). These diagnostics are outside the added diff lines, so they cannot be anchored inline, but the branch needs to typecheck cleanly before merge.
— GPT-5 via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| requestToSend = [...systemReminders, ...requestToSend]; |
There was a problem hiding this comment.
[Critical] These generated runtime-context reminders are prepended to requestToSend, and GeminiChat.sendMessageStream persists that full user content into chat history before sending. After a caller updates or removes a key, the old <system-reminder>[key] ...</system-reminder> remains in prior user turns and is sent again on future requests, so stale rules or operator data can keep influencing the model and continue being sent to the provider. Treat runtime context as a request-only overlay, or tag/filter these generated reminder parts before committing user content to history, and add a set -> send -> remove/update -> send regression test.
— GPT-5 via Qwen Code /review
| rejected: Array<{ key: string; reason: string }>; | ||
| }> { | ||
| return await this.fetchWithTimeout( | ||
| `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/runtime-context`, |
There was a problem hiding this comment.
[Critical] This new SDK method goes through this.transport.fetch(...), but ACP HTTP/WS transports do not hit the REST Express route directly; they first map URL-shaped calls through acpRouteTable.ts. There is no /session/:id/runtime-context mapping and no matching _qwen/session/runtime_context dispatcher, so clients using negotiateTransport() can select ACP and get a synthesized 404 even though the REST route works. Add the route-table entry, ACP dispatch handler, and transport tests for this API.
— GPT-5 via Qwen Code /review
| const config = session.getConfig(); | ||
| const appliedKeys: string[] = []; | ||
| const rejected: Array<{ key: string; reason: string }> = []; | ||
| for (const [key, value] of Object.entries( |
There was a problem hiding this comment.
[Critical] Processing the batch strictly in object order makes capacity handling depend on caller key order. If the store already has 16 entries and a caller sends { newKey: 'v', oldKey: '' }, newKey is rejected as capacity_full before oldKey is removed; the same logical update succeeds if the deletion appears first. Apply empty-string removals before non-empty sets, or compute capacity against the post-removal store, so a single batch has deterministic semantics.
— GPT-5 via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No new findings in this re-review. 9 parallel review agents + verification + reverse audit confirmed that all significant concerns are already covered by prior review comments.
Build & tests: ✅ Build passes. All 17 new tests pass (10 Config RuntimeContext, 1 client injection, 6 acpAgent handler). ESLint clean. 5 pre-existing tsc errors in server.test.ts at unchanged lines (not caused by this PR).
Note: The prior Critical comment about test crashes (mcpServers is not iterable at acpAgent.test.ts:7552) appears stale — verified that line 7560 passes mcpServers: [] and all 6 handler tests pass successfully.
— qwen3.7-max via Qwen Code /review
|
Thanks — the need itself is clear: sessions sharing a The way I think about it: qwen-code core is a single-session engine. It runs one conversation for one caller, with one set of context — the system prompt plus RuntimeContext — context that differs per session, varies per client, and mutates at runtime — is multi-tenant state in exactly that sense. A single-session user never reaches for it; they edit Putting it in core inverts that layering: the engine starts carrying a notion that exists only because of the layer above it. Once, it's harmless; as a habit, it's how a clean, embeddable engine slowly turns into one that has to know about every consumer's needs. So the real question, before any implementation: is there something that genuinely requires this to live in the engine rather than at the serve boundary? If there is, let's name it and weigh it. If not, I'd keep core single-session-pure and own this in the daemon, where the per-tenant state already lives. 中文感谢提交——需求本身很清楚:共享同一个 我的理解是这样的:qwen-code core 是一个单会话引擎。它为单个调用方运行一段对话,只有一套上下文——系统提示词加上 RuntimeContext——按会话不同、随客户端变化、并在运行时可变的上下文——正是这种意义上的多租户状态。单会话用户从不需要它;他们改 把它放进 core 会让分层倒置:引擎开始承载一个仅仅因为上层才存在的概念。偶尔一次无伤大雅;一旦成为习惯,一个干净、可嵌入的引擎就是这样慢慢变成一个不得不了解每个消费方需求的引擎。 所以在动手实现之前,真正要问的是:是否存在某种东西,确实要求它必须落在引擎里、而不能放在 serve 边界?如果有,我们把它点出来、一起权衡。如果没有,我倾向于让 core 保持纯单会话,把这件事交给 daemon——按租户的状态本就都在那里。 |
|
@tanzhenxin Thanks for the thoughtful layering analysis — I agree with the framing and have updated the PR description accordingly (removed all daemon-specific business context, repositioned as a generic SDK-level capability aligned with the Claude Agent SDK's system prompt modification patterns). On the core vs daemon question — you're right that this is conceptually multi-tenant state. Let me lay out why the current implementation touches core, and whether the alternative works: Why core today: The injection point is The daemon-only alternative would be:
None of these give "external caller sets key-value context that appears as The minimal core surface: The actual core footprint is intentionally tiny — a That said, I'm open to restructuring if there's a cleaner boundary. One option: move the Map storage to the bridge layer and use the existing Also noting: I've updated the PR description to remove all references to specific downstream consumers and reposition this as a generic capability — analogous to Claude Agent SDK's |
|
Thanks — this is a strong reply, and it moved me off part of my position. Let me drop what doesn't hold and sharpen what does. You're right that the layering objection isn't the real issue. But following your own " First, what this carries — operator identity, per-session rules — is session-constant: set once, unchanged turn to turn. Per-turn Second, the Does the value actually need to change mid-conversation, or only be set once near session start? The PR's own shape points to the latter: creation-time seeding ( So I'd suggest right-sizing it: start with the set-once capability, and add the per-turn mutable channel when a concrete mid-conversation update actually shows up. Could you point to a case where the value must change during a conversation, rather than be fixed when the session opens? If there's a solid one, let's design around that trigger specifically. If not, I'd land the minimal version now and keep the door open. 中文感谢回复——这是一个很有分量的回应,也让我放下了之前的一部分判断。我先把站不住的部分撤掉,把站得住的部分讲清楚。 你说得对,分层并不是真正的症结。 但顺着你自己" 第一,它承载的东西——操作人身份、按会话的规则——是会话级常量:设置一次,逐轮不变。而按轮的 第二, 这个值真的需要在会话进行中改变吗,还是只需在会话开始时设置一次? 这个 PR 自身的形态指向后者:创建时预设( 所以我建议把它收到合适的尺寸:先做"设置一次"的能力,等到真的出现一个会话进行中需要更新的具体场景时,再加上按轮的可变通道。你能否举出一个值必须在会话进行中改变、而不是在会话开启时就固定下来的例子?如果有扎实的例子,我们就针对那个触发点来专门设计;如果没有,我倾向于先把最小版本落地,同时给后续留好口子。 |
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM.
— GPT-5 via Qwen Code /review
|
@qwen-code /resolve |
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution SummaryPR #5847: feat(serve): add runtime context injection for per-turn system-remindersConflicts FoundTwo files had content conflicts when merging
Root CauseMain branch commit
The PR branch still had all this code inline in Resolutions1.
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Re-review with qwen3.7-max — no new findings. All significant concerns are already covered by prior review comments. Downgraded from Approve to Comment: CI still running.
Build & tests: Build passes, all 17 new tests pass (10 Config RuntimeContext + 6 ACP handler + 1 client injection), eslint clean. 7 pre-existing tsc errors in server.test.ts and integration-tests are not caused by this PR.
Needs Human Review:
- The
entries === nullandArray.isArray(entries)validation branches in the handler have no dedicated test coverage (the single invalid-entries test sends a string). - The bulk
setRuntimeContext()test only covers valid entries; silent-discard behavior for invalid entries is unpinned.
— qwen3.7-max via Qwen Code /review
|
@qwen-code /resolve |
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution SummaryPR: #5847 - feat(serve): add runtime context injection for per-turn system-remindersConflicts EncounteredTwo test files had conflicts when merging
Nature of ConflictsBoth conflicts were additive — the PR branch and main branch added new, independent test blocks at the same location in the file. These were not true semantic conflicts, just positional overlaps. Resolution StrategyKept both sides — all new test blocks from both the PR branch and main branch were preserved. File 1:
|
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution Summary — PR #5847Base branch
Conflicted files1.
|
wenshao
left a comment
There was a problem hiding this comment.
Review of the runtime-context feature. The core mechanism (Config store → per-turn injection with escapeSystemReminderTags → ACP ext-method → daemon route → SDK) is coherent and value-escaping is handled correctly. My main concern is a large, unrelated test deletion that appears accidental; the rest are cleanup/scope notes.
🤖 Reviewed with Claude Code (Opus 4.8)
| }); | ||
| }); | ||
|
|
||
| describe('Server Config (config.ts)', () => { |
There was a problem hiding this comment.
This PR removes ~450 lines of test coverage unrelated to runtime-context. Deleted here: describe('matchesServerPattern') / matchesAnyServerPattern, the team-memory security-gating tests (including the TeamMemoryRootSecurityError symlink-escape refusal — a security regression guard), describe('computer use settings'), and the modalities hot-switch assertions in Model Switching.
All of the code under test still exists in config.ts (matchesServerPattern @668, getComputerUseIdleTimeoutMs @5087, getEffectiveInputModalities @3063, the team-memory sync gate in refreshHierarchicalMemory), so this is live coverage being dropped — most likely a bad rebase/merge artifact rather than an intentional change. Please restore these blocks; the runtime-context tests should be purely additive.
| } | ||
| } else if (config.setRuntimeContextEntry(key, value)) { | ||
| appliedKeys.push(key); | ||
| } else if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) { |
There was a problem hiding this comment.
The rejection-reason classification re-implements Config's validation constants (/^[a-zA-Z0-9_-]{1,64}$/ and 32 * 1024) that already exist as Config.RUNTIME_CONTEXT_KEY_RE / RUNTIME_CONTEXT_MAX_VALUE_BYTES. If those limits change in config.ts, this handler will silently misclassify the reason (e.g. report capacity_full for a value that is actually too large, or invalid_key under a widened regex). Consider having setRuntimeContextEntry return a typed reason instead of a boolean so there's a single source of truth for both the accept/reject decision and its cause.
|
|
||
| private getOwnRuntimeContextEntries(): Map<string, string> { | ||
| if ( | ||
| !Object.prototype.hasOwnProperty.call(this, 'runtimeContextEntries') |
There was a problem hiding this comment.
getOwnRuntimeContextEntries guards with hasOwnProperty('runtimeContextEntries') and lazily re-creates the Map, but the field is initialized inline (private runtimeContextEntries: Map<...> = new Map()), so every constructed Config already has it as an own property — the guard never fires. Unless there's a real path that builds Config without running field initializers (Object.create/prototype reuse), this helper can collapse to return this.runtimeContextEntries;.
| .codegraph | ||
| .qwen/computer-use/installed.json | ||
| .playwright-mcp/ | ||
| pnpm-lock.yaml |
There was a problem hiding this comment.
pnpm-lock.yaml looks unrelated to this feature — the repo uses npm (package-lock.json). If it leaked in from a local pnpm setup, please drop it to keep the PR scoped to runtime-context.
|
|
||
| app.post( | ||
| '/session/:id/runtime-context', | ||
| mutate(), |
There was a problem hiding this comment.
[Suggestion] This route uses mutate() (non-strict auth gate). Most comparable persistent session-state routes in this file use mutate({ strict: true }) — including approval-mode (L1691), shell (L1575), artifacts (L637), continue (L756), goal/clear (L745), and rewind (L1664). However, model (L1364), language (L1733), and recap (L1394) also use the non-strict mutate(), so this follows an existing subset of the convention.
The concern is that runtime context values are injected as <system-reminder> blocks on every future model turn — unlike a model or language switch, which are one-time settings. Any local process on the machine (compromised dependency, co-tenant, malicious npm package) can POST persistent instructions that the model sees as trusted context on every subsequent turn indefinitely. This makes runtime context a higher-value injection target than a model name or language preference.
Consider using mutate({ strict: true }) to match the majority of persistent-state routes, or add a code comment explaining why non-strict is intentional for this route.
| mutate(), | |
| mutate({ strict: true }), |
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
|
@qwen-code /resolve |
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
@qwen-code /resolve |
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution Summary — PR #5847Base branch
ConflictFile:
|
|
Qwen Code did not run conflict resolution for this request. PR #5847 does not currently have merge conflicts with main. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Build failure: SDK browser bundle exceeds 137 KiB limit by 90 bytes — the new DaemonClient.setSessionRuntimeContext and DaemonSessionClient.setRuntimeContext methods push the bundle to 140,378 bytes (MAX_DAEMON_BROWSER_BUNDLE_BYTES = 140,288). Build fails at assertBrowserSafeBundle. Fix: bump the limit or reduce dead code.
Missing test: No test exercises the sessionRuntimeContext handler with a valid-but-nonexistent sessionId. The existing "missing sessionId" test is caught by the typeof guard before reaching sessionOrThrow. The most common production error path (stale SDK session ID) is untested.
— qwen3.7-max via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| async setSessionRuntimeContext( |
There was a problem hiding this comment.
[Critical] SDK browser bundle size limit exceeded.
The new setSessionRuntimeContext method (and its DaemonSessionClient wrapper) push the browser daemon SDK bundle to 140,378 bytes, exceeding MAX_DAEMON_BROWSER_BUNDLE_BYTES (137 × 1024 = 140,288) by 90 bytes. The build fails at assertBrowserSafeBundle in packages/sdk-typescript/scripts/build.js:222.
| async setSessionRuntimeContext( | |
| // In packages/sdk-typescript/scripts/build.js, bump the limit: | |
| const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 138 * 1024; |
— qwen3.7-max via Qwen Code /review
| return false; | ||
| } | ||
| const entries = this.getOwnRuntimeContextEntries(); | ||
| if (!value) { |
There was a problem hiding this comment.
[Suggestion] !value is a falsy check, but the documented deletion semantic is value === '' (empty string). This means hypothetical falsy non-strings (0, false) would also trigger deletion.
The bulk companion setRuntimeContext (line 4666) uses value && (truthy guard), which skips falsy values instead of deleting — creating divergent behavior for the same input across the two methods.
| if (!value) { | |
| if (value === '') { | |
| entries.delete(key); | |
| return true; | |
| } |
And in setRuntimeContext, replace value && with value !== '' &&.
— qwen3.7-max via Qwen Code /review
| getWorkspaceContext: vi.fn().mockReturnValue({}), | ||
| getDebugMode: vi.fn().mockReturnValue(false), | ||
| getRuntimeContext: vi.fn().mockReturnValue(runtimeCtxMap), | ||
| setRuntimeContextEntry: vi |
There was a problem hiding this comment.
[Suggestion] Mock omits the !value deletion branch that exists in production Config. When called with value === '', the mock falls through to runtimeCtxMap.set(key, '') (stores empty string), while production Config does entries.delete(key) (removes the entry).
Today this is harmless because the handler routes empty strings through removeRuntimeContextEntry. But a future refactor consolidating the handler's set/delete paths into a single setRuntimeContextEntry call would let mock-based tests pass while production behavior diverges.
| setRuntimeContextEntry: vi | |
| setRuntimeContextEntry: vi | |
| .fn() | |
| .mockImplementation((key: string, value: string) => { | |
| if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) return false; | |
| if (!value) { runtimeCtxMap.delete(key); return true; } | |
| if (Buffer.byteLength(value, 'utf8') > 32 * 1024) return false; | |
| if (!runtimeCtxMap.has(key) && runtimeCtxMap.size >= 16) return false; | |
| runtimeCtxMap.set(key, value); | |
| return true; | |
| }), |
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Stepping back to reflect on the full picture.
The runtime context injection feature itself is well-designed and cleanly implemented. The approach — Config KV store, per-turn system-reminder injection with proper tag escaping, ACP ext-method, bridge, daemon route, SDK — follows existing patterns in the codebase. The 17 new unit tests are thorough and all pass. The capability declaration is properly registered. If this PR contained only the feature code, it would be straightforward to approve.
But the PR carries significant baggage that blocks merge:
1. Build failure. The SDK bundle size guard trips (140378 > 140288 bytes). This is a hard blocker — npm run build must pass. The fix is straightforward (bump the limit in scripts/build.js), but it needs to happen in this PR since the PR's own SDK additions caused the overflow.
2. Test coverage regressions. The 437 lines of test deletions include:
matchesServerPattern/matchesAnyServerPattern— still exported and used in 3 production files, now untested.- Team memory security tests — symlink-escape gate, untrusted-workspace isolation, sync gate proofs. These protect against real security scenarios.
buildContextUsageand modalities assertions — unrelated functionality.
Removing tests for code that's still in production is a coverage regression, not cleanup. If these tests are genuinely obsolete (e.g., the functions were refactored elsewhere), the PR body should explain why. As-is, it reads as drive-by cleanup bundled with a feature.
3. Scope. The .gitignore addition (pnpm-lock.yaml) and the modalities test change are small but clearly unrelated. They belong in separate PRs.
My recommendation: Split this into two PRs:
- PR A: Runtime context feature only (config store, injection, handler, route, SDK, capability, tests). Bump the SDK bundle size limit.
- PR B: Test cleanup (with justification for each deletion, especially the security tests and the still-active
matchesServerPatterntests).
The feature code is ready — once the scope is tightened and the build passes, this should merge cleanly.
中文说明
退一步反思整体情况。
运行时上下文注入功能本身设计良好、实现干净。方案——Config KV 存储、按轮 system-reminder 注入(带正确的标签转义)、ACP ext-method、bridge、daemon 路由、SDK——遵循代码库现有模式。17 个新单元测试全面且全部通过。能力声明正确注册。如果这个 PR 只包含功能代码,审批会很直接。
但 PR 夹带了显著阻碍合并的包袱:
1. 构建失败。 SDK 包大小限制触发(140378 > 140288 字节)。这是硬阻塞——npm run build 必须通过。修复很简单(提高 scripts/build.js 中的限制),但需要在这个 PR 中完成,因为是 PR 自身的 SDK 新增代码导致了溢出。
2. 测试覆盖退化。 437 行测试删除包括:
matchesServerPattern/matchesAnyServerPattern— 仍在 3 个生产文件中导出和使用,现在没有测试。- Team memory 安全测试 — 符号链接逃逸门控、不可信工作区隔离、同步门控证明。这些防护真实的安全场景。
buildContextUsage和 modalities 断言 — 不相关的功能。
删除仍在生产中使用的代码的测试是覆盖退化,不是清理。如果这些测试确实过时了(例如函数在其他地方被重构了),PR 正文应该解释原因。目前看来像是与功能捆绑的顺手清理。
3. 范围。 .gitignore 新增(pnpm-lock.yaml)和 modalities 测试变更虽小但明显不相关。应该放在单独的 PR 中。
我的建议: 拆成两个 PR:
- PR A: 仅运行时上下文功能(config 存储、注入、处理器、路由、SDK、能力声明、测试)。提高 SDK 包大小限制。
- PR B: 测试清理(为每个删除提供理由,尤其是安全测试和仍在使用中的
matchesServerPattern测试)。
功能代码已就绪——一旦范围收紧、构建通过,应该可以顺利合并。
— Qwen Code · qwen3.7-max
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No new findings at this HEAD. 9 parallel review agents + verification + reverse audit confirmed that all significant concerns are already covered by prior review comments. Build passes, all 17 new tests pass, typecheck and lint clean.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Re-review with qwen3.7-max — 2 confirmed Critical findings (both already covered by prior review threads, inline comments skipped to avoid duplicates). 2 low-confidence Suggestions for human review.
Confirmed Critical (existing threads):
- config.test.ts test deletion — 24 unrelated tests deleted for unchanged production code (matchesServerPattern, MCP glob, team memory security gating, modalities). Covered by prior comments at lines 15, 18, 352, 6028.
- SDK
setRuntimeContextnaming mismatch — name implies replace but does merge. Covered by prior comments at line 452.
Needs Human Review (low confidence):
- No error boundary around runtime context injection in client.ts:2316 — a crash in
escapeSystemReminderTagswould kill the entire turn rather than skipping entries gracefully. - Zero observability at injection point — no debug log when runtime context entries are injected, making 3 AM debugging harder.
Build passes, all 17 new tests pass, typecheck and lint clean. — qwen3.7-max via Qwen Code /review
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
packages/core/src/core/client.ts:2316 |
No error boundary around runtime context injection — crash kills entire turn | Wrap injection block in try/catch, log warning and skip entries on failure |
packages/core/src/core/client.ts:2316 |
Zero observability at injection point — no debug log when entries are injected | Add logger.debug('Injected %d runtime context entries', runtimeCtx.size) |
Low-confidence findings — needs human review.
— qwen3.7-max via Qwen Code /review
🧪 Local validation report (maintainer)Built this PR from source and ran the reviewer test plan against a real Verdict: the storage + API layer is solid, but the core feature — per-turn injection — does not work end-to-end. Test-plan step 4 ("the model should see Environment
Results
❌ Blocking issue: runtime context is stored but never injected in the daemon pathWith runtime context Root-caused with a per-instance config trace: Root cause — the injection was added to the wrong
So the writer set and the injector set never intersect. The value is stored on the correct config instance (the trace above confirms Suggested directionInject the runtime-context reminders on the path the daemon actually runs — e.g. inside How this was reproducedA temporary integration test spun up a real
🇨🇳 中文说明(点击展开)🧪 本地验证报告(维护者)我从源码构建了本 PR,并对着真实的 结论:存储 + API 层是扎实的,但核心功能——按轮注入——端到端并不生效。 测试计划第 4 步("模型应看到 环境
结果
❌ 阻断性问题:runtime context 被存储了,但在 daemon 路径下从未被注入在一个活跃会话上设置 用逐实例的 config 追踪定位到根因: 根因——注入被加到了错误的
因此写入方集合与注入方集合永不相交。值确实被存到了正确的 config 实例上(上面的追踪显示轮次执行时 建议方向把 runtime-context 提醒注入到 daemon 真正执行的路径上——例如放进 复现方式一个临时集成测试拉起真实
🤖 Report generated by an automated local validation run (build + real-daemon E2E + outbound-request capture). Figures above are from actual captured output. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Re-review at this HEAD — no new findings. 10 parallel review agents + verification + reverse audit confirmed all significant concerns are already covered by prior review comments. Build passes, all 17 new tests pass (10 Config RuntimeContext, 6 acpAgent handler, 1 client injection), typecheck clean.
Open Criticals checked against current code:
| Critical | Status |
|---|---|
| config.test.ts — ~650 lines of unrelated tests deleted | still stands |
| config.test.ts:6028 — modalities assertions removed | still stands |
| DaemonClient.ts — SDK method bypasses acpRouteTable | still stands |
| acpAgent.ts:7217 — batch processing order non-deterministic | still stands |
| client.ts:2324 — runtime-context persisted in chat history | still stands |
| acpAgent.test.ts:11298 — mcpServers not iterable | fixed |
| DaemonClient.ts — SDK browser bundle size | cannot tell |
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Re-review with qwen3.7-max — no new findings. 12 parallel review agents + verification confirmed that all significant concerns are already covered by prior review comments.
Open Critical re-check at this HEAD:
- Fixed: test crash (
mcpServers is not iterable) —setupAgent()now passesmcpServers: [](line 11306). Missing closing braces — file properly terminated with});. - Still stands: deleted test coverage (~650 lines for
matchesServerPattern, MCP glob filtering, team memory sync gating),modalitiesassertions removed from model-switch test, batch processing order dependency on object key order, chat history persistence of runtime-context reminders. - Cannot tell: SDK route table mapping (requires runtime verification beyond static diff analysis), SDK browser bundle size (CI shows all 28 checks passing).
Build & tests: Build passes, all 6 sessionRuntimeContext handler tests pass. Config and client tests blocked by pre-existing @xterm/headless ESM interop issue (not introduced by this PR).
— qwen3.7-max via Qwen Code /review
|
@callmeYe Thanks for your work on this — since the thread has been quiet for a couple of weeks, let me summarize where the review stands and what it would take to move forward. The need itself is real and worth supporting. Sessions sharing one 1. The core design question is still unanswered@tanzhenxin's question from Jun 26 remains open: is there a concrete case where the value must change mid-conversation, rather than be fixed when the session opens? Every use case named so far (operator identity, per-session rule overlays) is session-constant. For those, a settable Unless a solid mid-session-mutation scenario shows up, I'd like to right-size this to the set-once capability first and add the mutable per-turn channel when a concrete trigger appears. That also sidesteps the ToolResult-turn staleness contract I flagged earlier. 2. The blocking wiring bug from the Jul 11 validation report is still presentThe current diff still injects only in
3. The diff now deletes ~437 lines of unrelated tests from
|
|
Closing this PR after re-evaluating both the use case and the current branch. The underlying requirement is still valid: an embedding client may need to update conversation-scoped rules or contextual state while a session remains active. The latest accepted snapshot should take effect on the next safe user turn, remain isolated to that session, and leave persistent workspace instructions unchanged. However, this branch should not be merged as-is:
Any follow-up should be a fresh, minimal PR based on current main, with an end-to-end daemon test that verifies the runtime context in the actual outbound model request. Tool and skill availability should remain outside this generic context contract because capability changes also require runtime enforcement. Thanks everyone for the detailed reviews and feedback. |
What this PR does
Adds a per-session key-value RuntimeContext store that external callers (daemon API, SDK) can populate with session-scoped dynamic context. Entries are injected as
<system-reminder>blocks on every UserQuery/Cron turn, providing a runtime-mutable layer between the static system prompt and the conversation — analogous to theappendmechanism in the Claude Agent SDK, but updatable mid-session.Full stack:
Configstore → per-turn injection insendMessageStream→ ACP ext-method → bridge implementation → daemon HTTP routePOST /session/:id/runtime-context→ SDKDaemonClient/DaemonSessionClient.Why it's needed
The Claude Agent SDK distinguishes between static system prompts (
systemPrompt,append) and dynamic conversation context (CLAUDE.md injected into conversation, not the system prompt). Qwen Code currently lacks an equivalent mechanism for session-scoped dynamic context that can be set at session creation and updated mid-session without modifyingQWEN.md.Use cases include: operator identity injection, per-session rule overlays, dynamic configuration that varies by session but shouldn't be persisted to project-level files. This keeps
QWEN.mdstable as a project-level configuration file while enabling session-level customization through the daemon/SDK API.Reviewer Test Plan
How to verify
npx vitest run packages/core/src/config/config.test.ts -t "RuntimeContext"— 10 unit tests covering CRUD, key/value/count validation, return-value accuracy, and prototype-chain isolation.npx vitest run packages/cli/src/acp-integration/acpAgent.test.ts -t "sessionRuntimeContext"— 6 tests covering happy path, removal, rejection, and error cases.qwen serve, create a session, thencurl -X POST /session/:id/runtime-context -d '{"entries":{"operator":"Alice","rules":"No prod changes"}}'. Verify the response contains{ keys, rejected }.<system-reminder>blocks containing[key] valueformatted content.GET /capabilitiesshould includesession_runtime_contextin the feature list.Evidence (Before & After)
N/A — new feature, no UI changes.
Tested on
Environment (optional)
Unit tests + integration tests. Integration testing requires a running daemon.
Risk & Scope
initialRuntimeContextonBridgeSpawnRequest(session-creation-time seeding) is designed but not wired — will land in a follow-up.中文说明
这个 PR 做了什么
新增了一个会话级键值 RuntimeContext 存储,外部调用方(daemon API、SDK)可以填充会话作用域的动态上下文。条目在每个 UserQuery/Cron 轮次以
<system-reminder>块注入,提供了一个介于静态系统提示词和对话之间的、运行时可修改的层——类似 Claude Agent SDK 的append机制,但支持会话中途更新。完整链路:
Config存储 →sendMessageStream中按轮注入 → ACP ext-method → bridge 实现 → daemon HTTP 路由POST /session/:id/runtime-context→ SDKDaemonClient/DaemonSessionClient。为什么需要
Claude Agent SDK 区分静态系统提示词(
systemPrompt、append)和动态对话上下文(CLAUDE.md 作为对话内容注入,而非系统提示词)。Qwen Code 目前缺少一个等价机制——能在会话创建时设置、且在会话中途通过 API 更新的会话级动态上下文。使用场景包括:操作人身份注入、按会话的规则叠加、因会话而异但不应持久化到项目级文件的动态配置。这让
QWEN.md保持稳定作为项目级配置文件,同时通过 daemon/SDK API 实现会话级定制。评审测试计划
如何验证
npx vitest run packages/core/src/config/config.test.ts -t "RuntimeContext"— 10 个单元测试覆盖增删改查、key/value/count 校验、返回值准确性和原型链隔离。npx vitest run packages/cli/src/acp-integration/acpAgent.test.ts -t "sessionRuntimeContext"— 6 个测试覆盖正常路径、删除、拒绝和错误场景。qwen serve,创建会话,然后curl -X POST /session/:id/runtime-context -d '{"entries":{"operator":"Alice","rules":"No prod changes"}}'。验证响应包含{ keys, rejected }。[key] value格式内容的<system-reminder>块。GET /capabilities应在功能列表中包含session_runtime_context。证据(前后对比)
不适用——新功能,无 UI 变更。
测试平台
风险与范围
BridgeSpawnRequest上的initialRuntimeContext(会话创建时预设)已设计但未接线——将在后续 PR 落地。