Conversation
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head 1877b4ae4b37654a3aa77ac4aed9e5e17e26b641.
This adds a DeepSeek-only Open Responses extension codec plus a fetch-layer discriminator adapter for web_search, web_search_call, and related SSE events. Product routing remains disabled.
I found two correctness gaps in the codec lifecycle; both are included as inline P2 comments. The main issue is that the exact-once replay test exercises an SDK-local carrier that Maka does not persist, so the claimed replay behavior does not survive the production event/history boundary.
Checks run on this head: core/storage/runtime builds; 74 focused tests passed; Biome on all four touched files; ASF header audit; git diff --check; clean merge-tree with current main 6105ae726079b456f63ebe17f624bf76f4d45bff. The full workspace typecheck was not conclusive because downstream packages were invoked before all referenced workspace dist exports were built. Hosted checks currently contain only the label job, not a test gate.
Not ready to merge until the inline findings are resolved.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| ): Experimental_OpenResponsesExtensionItem | undefined { | ||
| const part = options.part; | ||
| if (part.type !== 'tool-call') return undefined; | ||
| const stored = |
There was a problem hiding this comment.
[P2] Preserve the replay carrier across the durable runtime boundary
storedReplayItem cannot recover the original item from the shape Maka actually persists. The Open Responses SDK puts the full opaque item on a separate custom replay carrier; the tool-call metadata contains only { id, itemId }. Maka drops custom stream chunks in model-adapter.ts and persists only that reference metadata on the tool call. I reproduced a second request from this persisted shape: the request contained no web_search_call item and emitted unsupported: provider-defined tool openai.web_search tool-result history. The current exact-once test passes only because it feeds first.content directly back before the runtime boundary. Please persist/reconstruct the full carrier and add a RuntimeEvent -> ModelMessage -> request regression test.
There was a problem hiding this comment.
Addressed on later commits (ed77ed1 and following). The opaque Open Responses item is now merged onto the provider-executed tool-call providerOptions so it survives RuntimeEvent persistence. deepseek-open-responses-extensions.test.ts covers RuntimeEvent → ModelMessage → request replay of the original web_search_call (including opaque fields). Please re-review when you have a moment.
| providerExecuted: true, | ||
| }, | ||
| ]; | ||
| if (item.status === 'completed' || item.status === 'failed' || options.mode === 'generate') { |
There was a problem hiding this comment.
[P2] Propagate failed search status as an error result
This branch emits the same ordinary tool-result for status: "failed" as for a completed search, without isError: true. Maka only marks provider results as failures when the SDK chunk is tool-error or carries isError, so a failed hosted search is persisted and displayed as a successful tool result. A focused doGenerate probe with a failed web_search_call produced a provider-executed result containing status: "failed" but no error flag. Please mark failed items as errors and cover the runtime mapping.
There was a problem hiding this comment.
Addressed on later commits. Failed web_search_call items now emit isError: true on the provider-executed tool-result, and model-adapter maps that through as an error result. Covered by marks a failed hosted search item as an error result and marks failed provider-executed tool results as errors.
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head 59cd3256ad498e58a360ff46cc520138c952a7f7.
This follow-up fixes both previously reported issues: the opaque Open Responses extension item now survives the RuntimeEvent/history boundary, and failed hosted searches are marked as error results. The durable replay test exercises the persisted event shape and sends the reconstructed item through the provider request path.
I found one remaining concurrency/lifecycle correctness issue in the new carrier state, included as an inline P2 comment. The ModelAdapter is shared by concurrent turns, but the pending replay map is shared across every physical stream.
Checks run on this head: clean npm ci; core/storage/runtime builds; 292 focused Runtime tests passed; changed-file Biome; ASF header audit; git diff --check; and a clean merge-tree with fetched main ec59d42f6021a4b40e18f10dad184a1821e6f0c4. GitHub reports MERGEABLE / BLOCKED, REVIEW_REQUIRED, and no hosted status checks for this head. The PR API still reports base OID 6105ae726079b456f63ebe17f624bf76f4d45bff, while the live main ref is newer. I did not call the real DeepSeek API.
Not ready to merge until the inline finding is resolved.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| private readonly runtime: ResolvedModelRuntime; | ||
| private readonly openAiChatReasoningTransportState: OpenAiChatReasoningTransportState; | ||
| private readonly openAiResponsesTransportState: OpenAiResponsesTransportState; | ||
| private readonly pendingOpenResponsesExtensionReplay = new Map<string, ProviderOptions>(); |
There was a problem hiding this comment.
[P2] Scope replay carriers to one physical stream
This map lives on the session-wide ModelAdapter, although AiSdkBackend explicitly permits multiple concurrent send() calls and routes every stream through the same translateChunk(). I reproduced the production startStream() path with two simultaneous streams using the same provider-controlled item id: after carrier A, carrier B, then tool-call A, A persisted B's opaque provider_trace; tool-call B then had no item at all. Entries also have no finish/error/dispose cleanup, so an interrupted stream can leave stale state behind. Keep this association inside toModelStreamResult() (one map per physical request, cleared in finally) and add a concurrent-stream/aborted-stream regression.
There was a problem hiding this comment.
Addressed on later commits. The pending replay map now lives inside toModelStreamResult() (one map per physical stream) and is cleared on every iterator termination path. Concurrent same-id streams and aborted-then-later-request regressions are in model-adapter.test.ts.
There was a problem hiding this comment.
Reviewed exact head bf65b76940b4e4ec40a0e2e2934676379cf0990c.
This follow-up resolves the previously reported concurrency/lifecycle issue. toModelStreamResult now owns the pending Open Responses replay-carrier map per physical provider stream (packages/runtime/src/model-adapter.ts:350-372) and clears it on every iterator termination path (:408-415). The regressions exercise concurrent streams using the same provider item id and an aborted stream followed by a later request (packages/runtime/src/__tests__/model-adapter.test.ts:806-900). I found no remaining P0-P3 issue in the current diff.
Validation on this head: clean Node 24.18.1 install; build:test; full workspace typecheck; Runtime 3495 passed / 13 skipped; focused 294/294; lint; format; ASF headers; and git diff --check. The merge tree with current main 4410c3a2d19d20cfdc6b7815bdd2d72d33a7460f is clean. Its focused run had one sandbox retry-count failure, which reproduces unchanged on pure current main and is not attributable to this PR. GitHub reports MERGEABLE / BLOCKED, REVIEW_REQUIRED, and no hosted status checks. I did not call the real DeepSeek API.
No technical blocker found on this head; final merge readiness still depends on repository review policy and live gates.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
Astro-Han
left a comment
There was a problem hiding this comment.
Review
Scope: codec correctness / round-trip fidelity, not a line-by-line pass. Most of the risk here is in how the codec lines up with the SDK's extension seam, so I cross-checked the diff against the pinned @ai-sdk/open-responses@2.0.44 and @ai-sdk/provider@4.0.14 in this checkout (node_modules/@ai-sdk/open-responses/dist/index.js / index.d.ts) rather than only against the patch.
Verified
-
Registration shape is valid for the pinned SDK.
createOpenResponsesExtensionRegistrythrows unlesstoolType+encodeTool,itemTypes+decodeItemandeventTypes+decodeEventare provided in pairs, andassertNamespacedTyperequires the namespace to match the extension id.deepseek-open-responses-extensions.ts:221registers all three pairs underopenai.web_searchwithopenai:*types — consistent in both the bare and namespaced branches. -
The extension id is the real provider-tool id in production, not just in tests.
compileProviderTool('openai-web-search')returnsopenai.tools.webSearch(...)(model-adapter.ts:1255), i.e. provider-tool idopenai.web_search, which is exactlyDEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID. So the extension binds to the tool Maka actually lowers, and the wrap maps it to DeepSeek's bareweb_search. -
Content-part shapes are V4-correct. The tool-result part uses
result+isError(LanguageModelV4ToolResult,@ai-sdk/providerd.ts:556), not the prompt-sideoutput.providerExecuted: trueon that part is load-bearing: Maka dropstool-resultchunks unlessproviderExecuted === true(model-adapter.ts:1126) and readschunk.output ?? chunk.result(:1140). Theas Experimental_OpenResponsesExtensionContentPartcast is therefore justified (the type omitsproviderExecutedon tool-results) — a one-line comment would stop it reading as a type escape hatch. -
Carrier contract matches what the SDK emits — kind
open-responses.extension-replay, payloadproviderMetadata[<provider name>].openResponsesExtension = { id, item }(dist:1451). Scanning all provider keys instead of assumingdeepseek(deepseek-open-responses-extensions.ts:137) is the right call, sinceruntimeProviderName()returns the connection slug foropenai-compatibleconnections (provider-runtime-policy.ts:81). -
No duplicate decode in streaming.
decodeItemis invoked only from the non-streaming path (dist:1058) andresponse.output_item.done(dist:1256);output_item.addedfor extension items is ignored, so thein_progressitem in the stream fixture cannot produce a second tool-call. -
Ordering works because the SDK emits the carrier first.
decodeExtensionItemreturns[carrier, ...decoded](dist:1414), sopendingOpenResponsesExtensionReplayis populated before the matchingtool-callchunk. The per-request map (model-adapter.ts:366, cleared at:414) plus the concurrency/abort tests (model-adapter.test.ts:806,:860) cover the shared-state risk properly. -
Exactly-once history replay. The SDK pushes an item carrying the full
itemonce (dedup ontype:id) and silently skips parts carrying only{id, itemId}(dist:344-360). The projection's split — carrier keeps the item, tool-call/tool-result keep the reference (ai-sdk-message-projection.ts:419-441) — is exactly what that encoder expects, and the durable test (deepseek-open-responses-extensions.test.ts:473) asserts a singleweb_search_callwith the opaque fields on the wire. -
Persistence round-trip closes.
tool_startpersistsproviderOptionsintofunction_callcontent (session-event-runtime-mapper.ts:268), the core schema allows it (runtime-event.ts:1097), andbuildRuntimeEventModelReplayPlancarries it onto thetool_callitem (model-history.ts:870). -
Feature detection is tight. Only
providerType === 'deepseek'gets the wrap and extensions, so Anthropic-compatible and OpenAI-compatible DeepSeek endpoints are untouched; unregistered provider tools still warn and drop (…extensions.test.ts:256);createRequestCustomizationFetchreturns upstream unchanged when there is nothing to customize (request-customization-fetch.ts:40), so themodel-factory.ts:174change adds no wrapper in the no-customization case. -
The
providerExecutedToolsflip is scoped. With the short-circuit atmodel-adapter.ts:177, non-Responses DeepSeek adapters were alreadytruevia thekind !== 'responses'clause, so the only behavior change is DeepSeek + open-responses.streamTextfiltersproviderExecutedout ofclientToolCallsand skips execution for it, so the "no client tool loop" claim holds.
The one item I'd like addressed
DeepSeek's hosted-pair replay degradation lost its only test (ai-sdk-backend.test.ts:3346, :3425). Those two tests were the regression guard for "Open Responses cannot round-trip a provider-executed pair → drop it, keep the grounded text". Because providerExecutedTools is now true for DeepSeek, they were re-pointed at alibaba-token-plan-cn instead of being updated, so:
- nothing asserts what DeepSeek now does with a persisted provider-executed pair without carrier
providerOptions— i.e. history written before this change, or any turn where the carrier merge didn't happen. That path takesencodeInputItem's synthesis branch (deepseek-open-responses-extensions.ts:375) and the SDK then warnsprovider-defined tool openai.web_search tool-result historywhile dropping the result. Maka consumes no SDKwarningsanywhere in the model path, so that signal is invisible. - the test name ("Open Responses cannot replay a hosted tool pair") no longer describes the fixture provider.
Latent today (routing stays implemented: false, so no such history can exist in product), but it becomes load-bearing when #3689 flips the flag. Cheapest fix: keep one of the two tests on DeepSeek with updated expectations (synthesized web_search_call item present, result dropped, grounded text retained).
Nits
- Dead code:
OUTGOING_EVENT_TYPES(deepseek-open-responses-extensions.ts:81) is never read — events only flow inbound. Neither gate catches it:biome.jsoncis an explicit allowlist (preset: "none") withoutnoUnusedVariables, andtsconfig.base.jsonsets nonoUnusedLocals. - The
allowBareTypesbranch is unreachable and untested (:221; the rewrite tests early-return when the probe is true,…extensions.test.ts:139/:191). Note the SDK's own guards require a:in the type for items and events (dist:167-180), so if upstream shipsallowBareTypeswithout relaxing those, this branch would register successfully and then silently stop decoding. A comment or a unit test constructing the bare variant directly would pin the assumption. decodeEventno-ops onsearching/completed(:403): the call/result materialize only fromoutput_item.done. Fine for DeepSeek's documented sequence, but ifoutput_item.doneis ever omitted, the turn ends with atool-input-startand no call/result.- generate vs stream parity (
:362):mode === 'generate'emits the tool-result even for a non-terminal status (in_progress), which streaming deliberately does not. Intentional? A comment would help. - Body handling (
:262,:492): every DeepSeek JSON request body is buffered and re-serialized even when no type matchesOUTGOING_TOOL_TYPES(rewriteDeepSeekOpenResponsesOutgoingBodyalways returns a fresh object, so the no-op case isn't detectable). With a request customization configured the body is serialized twice per request. Cheap win: returnundefinedwhen nothing changed. translateChunk's new third parameter defaults to a freshMap(model-adapter.ts:526), so a future caller that forgets it silently loses carriers with no error. Consider making it required.usesDeepSeekOpenResponsesExtensions(providerType: string)(:125) accepts a plainstring; the repo has aProviderTypeunion and both callers pass it.- The streaming test bypasses
streamText(…extensions.test.ts:670drivesmodel.doStreamdirectly). Maka's runtime always goes throughstreamText, whereparseToolCallvalidates against the provider tool's (absent → empty-object) schema. The carrier path is covered at thestartStreamlevel with hand-crafted chunks, but no test drives an SDK-generated carrier throughstartStream/AiSdkBackend; worth adding alongside #3689. - Durable payload size: the opaque item is now persisted inside every provider-executed
function_callevent'sproviderOptions, so it also flows through the history-compaction/recap paths that cloneproviderOptions. Bounded byweb_search_call.actiontoday — flagging for whoever owns compaction.
ae646fb to
ac9bd40
Compare
|
Following up on @Astro-Han's review (DeepSeek hosted-pair replay degradation coverage + nits). Addressed on the latest head (
Would appreciate a re-review when you have a moment. Thanks! |
ec176e2 to
60476fc
Compare
|
@Astro-Han @hqhq1025 — re-review request after the DeepSeek hosted-pair follow-up and a rebase onto current Astro-Han must-fix: DeepSeek no-carrier replay coverage is back in
Alibaba keeps Cheap nits: unused hqhq1025 threads: carrier persistence, failed-search Focused checks on this head:
Would appreciate a re-review. Thanks! |
Astro-Han
left a comment
There was a problem hiding this comment.
The codec work itself is solid — verified clean on the trust boundary that mattered most: the discriminator wrap is exact Map.get membership both directions (no prefix/substring leaks), scoped to providerType === 'deepseek' only, content-length deleted on rewrite, non-JSON/SSE bytes pass through untouched; replay consumes the carrier per physical stream with item.id === toolCallId matching; provider-executed calls never reach returnedToolCalls so no phantom client tool calls; history degradation is honest (unmatched calls dropped with diagnostics, grounded text preserved); encode emits exactly {type:'web_search'} with search_context_size/user_location never reaching the wire; implemented:false scope holds and Tavily/Anthropic paths are untouched.
One P1 and one P2 below — both are consequences of DeepSeek becoming a producer of provider-executed exchanges, which the surrounding machinery wasn't built for.
P2 — provider-executed web_search exchanges replay malformed on non-Responses wires. emitStep (ai-sdk-message-projection.ts:407-432) emits provider-executed call/result parts into any admitted plan with no origin-provider check, and providerExecutedTools now admits them for deepseek on every wire. Two concrete breakages on mid-session model switch (②):
- DeepSeek →
deepseek-chat/deepseek-reasoner(same connection, chat wire):convert-to-openai-compatible-chat-messagesemits everytool-callpart as assistanttool_callsignoringproviderExecuted, while the carrier andtool-resultparts drop silently — the wire getstool_calls:[{name:'WebSearch'}]with no matchingtoolmessage, which OpenAI-compatible endpoints reject → every subsequent request fails until history compacts past the step. - DeepSeek → Anthropic with hosted
web_searchoffered: the call becomesserver_tool_use{name:'web_search', input:<deepseek action>}and the result failswebSearch_20250305OutputSchemavalidation → prompt conversion throws every turn.
Verified safe directions: native OpenAI Responses drops the pair cleanly; generic open-responses providers stay fail-closed; Anthropic without the tool offered warns and drops. The defect class predates the PR, but this creates the first routine producer of provider-executed calls for deepseek — worth fixing before sibling #3689 flips implemented. Minimal fix: gate provider-executed exchange emission on the target adapter recognizing the exchange's replay state, or at minimum drop them on openai-chat-plaintext wires instead of emitting as client tool_calls.
P3s:
ai-sdk-message-projection.ts:431—replayReferencepropagates the call's providerOptions onto the result for all providers, not just extension ones: an Anthropic provider-executed result now inherits{anthropic:…}caller options where main attached none. Possibly benign, possibly a latent fix, but a behavior change outside stated scope — haveopenResponsesExtensionReplayReferenceOptionsreturnundefinedunless it actually projected an extension reference.openResponsesSupportsBareExtensionTypes()probes by construction — a flag-only upstream release (acceptsallowBareTypes, parsers still require:) flips registration to bare types and silently stops decoding; the file's own comment notes this. Simplest fix is deleting the bare branch — the namespaced registration + discriminator wrap already emits the correct bare wire regardless; alternatively make the probe exercise a real decode path.- Replay dedup assumes provider item ids are unique across the whole request history (
${type}:${id}) — if DeepSeek reusesweb_search_callids per response, a later genuinely-different search is silently deduped. Unverifiable without DeepSeek's id scheme; worth pinning in the test plan.
| // replay is open for its hosted items; other Open Responses providers | ||
| // stay fail-closed. | ||
| providerExecutedTools: | ||
| usesDeepSeekOpenResponsesExtensions(this.input.connection.providerType) || |
There was a problem hiding this comment.
P1 — provider-executed web_search_call ends the turn as tool-calls → step_limit → recorded failed. With this extension, @ai-sdk/open-responses counts provider-executed parts in hasToolCalls with no providerExecuted exclusion (open-responses-language-model.ts:459,620-622 — contrast @ai-sdk/openai's hasFunctionCall, which deliberately counts only client calls). So a DeepSeek response carrying web_search_call + a final answer maps to finishReason:'tool-calls'; ai-sdk-turn.ts:2447-2451 then reports step_limit whenever maxSteps is defined — which is always for handoff continuations, child executions, and eval budgets (the repo's own deepseek eval fixtures set maxSteps). mapCompleteStopReason('step_limit') → failed → tool_step_cap_reached. A turn that successfully answered via hosted search records as failed on the exact model this PR targets. Decode is live today even with implemented:false (a web_search_call in any response decodes regardless of whether the tool was offered), and it becomes ① the moment #3689 flips. Minimal fix: only label step_limit when client work was actually pending — finishReason === 'tool-calls' && returnedToolCalls.length > 0 — or suppress when the step's only tool activity was provider-executed (attemptSawToolActivity is already tracked). The upstream fix belongs in @ai-sdk/open-responses (match hasFunctionCall semantics), but Maka needs the guard regardless until that lands.
| toolCallId: result.toolCallId, | ||
| toolName: result.toolName, | ||
| output: await materializeReplayToolResult(result, call.toolName), | ||
| ...(replayReference !== undefined ? { providerOptions: replayReference } : {}), |
There was a problem hiding this comment.
P3 — replayReference now propagates the call's providerOptions onto results for all providers. openResponsesExtensionReplayReferenceOptions returns the options unchanged when there's no openResponsesExtension inside, so a non-extension provider-executed result (e.g. Anthropic web_search_tool_result) now inherits the call's provider options where main attached none — getAnthropicCaller/getCacheControl then read them downstream. Return undefined from the helper unless it actually projected an extension reference.
Register DeepSeek hosted web_search codecs on createOpenResponses. The runtime already pins @ai-sdk/open-responses@2.0.44; until vercel/ai#19939 ships allowBareTypes, map only DeepSeek's documented bare discriminators at the network boundary. Hosted search stays fail-closed. Generated-by: Cursor Cloud Agent (Grok 4.6)
…tatus Merge the Open Responses custom replay carrier onto provider-executed tool-call metadata so the opaque web_search_call item survives RuntimeEvent persistence, reconstruct it on replay, and mark failed hosted searches as errors. Keep product hosted-search routing fail-closed. Generated-by: Cursor Cloud Agent (Grok 4.6)
Keep Open Responses extension-replay pending items on the per-request stream in toModelStreamResult instead of the session-wide ModelAdapter, and clear the map on success, error, and abort so concurrent send() calls with the same provider item id cannot mix opaque carriers. Generated-by: Cursor Cloud Agent (Grok 4.6)
Generated-by: OpenAI Codex
Drop the unused outgoing event map, return undefined from the fetch-layer rewrite when no allowlisted discriminator changed, and document generate vs stream result parity plus the allowBareTypes parser assumption. Generated-by: Cursor Cloud Agent (Grok 4.6)
A no-op discriminator wrap must not hand the upstream fetch an ArrayBuffer; request mocks and JSON.parse(String(body)) still expect the original text payload. Generated-by: Cursor Cloud Agent (Grok 4.6)
Gate provider-executed WebSearch emission on the target adapter recognizing
the exchange. Chat Completions no longer emit dangling tool_calls, and
Anthropic no longer coerces DeepSeek search into server_tool_use. Also
return undefined from replay-reference projection unless an extension
reference was produced, delete the unreachable allowBareTypes branch, and
pin SDK ${type}:${id} replay dedup.
Generated-by: Cursor Cloud Agent (Grok 4.6)
60476fc to
194e438
Compare
Provider-executed calls often carry no origin metadata; the matching tool-result does. Gate both items from the paired exchange so Anthropic hosted search still replays when only the result is schema-shaped. Generated-by: Cursor Cloud Agent (Grok 4.6)
|
@Astro-Han — follow-up on your 2026-09-20 re-review ( P2. Provider-executed hosted
Regressions: P3.
Focused checks: core/storage/runtime build; Biome clean; hosted-pair / switch tests 6/6; extension suite 16/16; model-adapter replay-gate / carrier / abort tests pass. Would appreciate another look. Thanks! |
Summary
Fixes #4107
Rebased onto current
apache/makamain(feb9cf22f). DeepSeek Open Responses codecs stay registered at thecreateOpenResponsesconstruction boundary; after theopenResponsesSdkModel()helper extraction on main, the wrap andexperimental_extensionsnow live in that helper so both Open Responses call sites stay aligned.@ai-sdk/open-responsesis already at 2.0.44 on main (newer than the issue’s 2.0.35), so this PR does not bump the package. It registers DeepSeek hostedweb_searchextension codecs:{ type: "web_search" }(DeepSeek ignoressearch_context_size/user_location)web_search_callinto provider-executedWebSearchcall/result pairsresponse.web_search_call.in_progress|searching|completedwithout entering the client tool loopweb_search), so mid-session model switch does not emit danglingtool_callsor invalidserver_tool_useUntil vercel/ai#19939 (
allowBareTypes) ships a parser-safe change,@ai-sdk/open-responses@2.0.44only accepts namespaced<implementor>:<type>registrations. This PR keeps namespaced registration plus the DeepSeek-only discriminator wrap. The unreachable bareallowBareTypesbranch is deleted so a flag-only upstream cannot silently stop decoding.Hosted-search product routing remains
implemented: false(sibling #3689). Tavily and Anthropic-compatible DeepSeek paths are unchanged.apply_patch/custom_tool_callis out of scope.Verification
npm --workspace @maka/{core,storage,runtime} run buildnpx biome checkon the touched files — cleanai-sdk-backend.test.jshosted-pair / model-switch tests: 6 passdeepseek-open-responses-extensions.test.js: 16 passnpm testsuiteAI use
Select exactly one:
Tool(s) and scope: Cursor Cloud Agent (Grok 4.6) implemented the codecs, discriminator wrap, mid-session replay gate, tests, rebase onto current main, and this PR.
Checklist
Does this PR entail a change in behavior?