From 37d7b2bf311247e4b6f02885218c1af99896200e Mon Sep 17 00:00:00 2001 From: Theophane Gregoir Date: Fri, 9 Jan 2026 17:59:23 +0100 Subject: [PATCH 1/5] dedupe --- chatkit/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chatkit/server.py b/chatkit/server.py index 65645f6..367b2ed 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -824,6 +824,7 @@ def _update_pending_items( ) case WorkflowItem(): if isinstance(update, (WorkflowTaskUpdated, WorkflowTaskAdded)): + updated_item = updated_item.model_copy(deep=True) match update: case WorkflowTaskUpdated(): updated_item.workflow.tasks[update.task_index] = update.task From 811575b4bce985965f483ee5fbec2d3ada647510 Mon Sep 17 00:00:00 2001 From: Theophane Gregoir Date: Fri, 9 Jan 2026 18:58:14 +0100 Subject: [PATCH 2/5] applying review --- chatkit/server.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/chatkit/server.py b/chatkit/server.py index 367b2ed..097ed7b 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -779,27 +779,25 @@ def _apply_assistant_message_update( | AssistantMessageContentPartAnnotationAdded | AssistantMessageContentPartDone, ) -> AssistantMessageItem: - updated = item.model_copy(deep=True) - # Pad the content list so the requested content_index exists before we write into it. # (Streaming updates can arrive for an index that hasn’t been created yet) - while len(updated.content) <= update.content_index: - updated.content.append(AssistantMessageContent(text="", annotations=[])) + while len(item.content) <= update.content_index: + item.content.append(AssistantMessageContent(text="", annotations=[])) match update: case AssistantMessageContentPartAdded(): - updated.content[update.content_index] = update.content + item.content[update.content_index] = update.content case AssistantMessageContentPartTextDelta(): - updated.content[update.content_index].text += update.delta + item.content[update.content_index].text += update.delta case AssistantMessageContentPartAnnotationAdded(): - annotations = updated.content[update.content_index].annotations + annotations = item.content[update.content_index].annotations if update.annotation_index <= len(annotations): annotations.insert(update.annotation_index, update.annotation) else: annotations.append(update.annotation) case AssistantMessageContentPartDone(): - updated.content[update.content_index] = update.content - return updated + item.content[update.content_index] = update.content + return item def _update_pending_items( self, @@ -807,6 +805,7 @@ def _update_pending_items( event: ThreadItemUpdatedEvent, ): updated_item = pending_items.get(event.item_id) + updated_item = updated_item.model_copy(deep=True) if updated_item else None update = event.update match updated_item: case AssistantMessageItem(): @@ -824,7 +823,6 @@ def _update_pending_items( ) case WorkflowItem(): if isinstance(update, (WorkflowTaskUpdated, WorkflowTaskAdded)): - updated_item = updated_item.model_copy(deep=True) match update: case WorkflowTaskUpdated(): updated_item.workflow.tasks[update.task_index] = update.task From 72483746d2cd63d49ef39c19ae19ecf0e8261120 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 9 Jan 2026 10:31:27 -0800 Subject: [PATCH 3/5] Moved model copy to pending item add time; add regression test --- chatkit/server.py | 5 +-- tests/test_chatkit_server.py | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/chatkit/server.py b/chatkit/server.py index 097ed7b..12f13ca 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -697,7 +697,9 @@ async def _process_events( with agents_sdk_user_agent_override(): async for event in stream(): if isinstance(event, ThreadItemAddedEvent): - pending_items[event.item.id] = event.item + # Stash an isolated copy in case we need to persist unfinished items + # on cancellation; downstream handlers keep using the original event.item. + pending_items[event.item.id] = event.item.model_copy(deep=True) match event: case ThreadItemDoneEvent(): @@ -805,7 +807,6 @@ def _update_pending_items( event: ThreadItemUpdatedEvent, ): updated_item = pending_items.get(event.item_id) - updated_item = updated_item.model_copy(deep=True) if updated_item else None update = event.update match updated_item: case AssistantMessageItem(): diff --git a/tests/test_chatkit_server.py b/tests/test_chatkit_server.py index ff7177c..6d593ca 100644 --- a/tests/test_chatkit_server.py +++ b/tests/test_chatkit_server.py @@ -33,6 +33,7 @@ AttachmentsDeleteReq, AttachmentUploadDescriptor, ClientToolCallItem, + CustomTask, FeedbackKind, FileAttachment, ImageAttachment, @@ -44,6 +45,7 @@ LockedStatus, Page, ProgressUpdateEvent, + ThoughtTask, Thread, ThreadAddClientToolOutputParams, ThreadAddUserMessageParams, @@ -79,6 +81,9 @@ UserMessageTextContent, WidgetItem, WidgetRootUpdated, + Workflow, + WorkflowItem, + WorkflowTaskAdded, ) from chatkit.widgets import Card, Text from tests._types import RequestContext @@ -354,6 +359,65 @@ def generate_item_id( ) +async def test_workflow_task_not_duplicated_on_done_event(): + """ + Regression test to make sure pending item updates do not modify the + origin item that was streamed. + """ + + async def responder( + thread: ThreadMetadata, input: UserMessageItem | None, context: Any + ) -> AsyncIterator[ThreadStreamEvent]: + workflow_item = WorkflowItem( + id="workflow-item", + created_at=datetime.now(), + thread_id=thread.id, + workflow=Workflow(type="custom", tasks=[]), + ) + + yield ThreadItemAddedEvent(item=workflow_item) + + task = CustomTask(title="foo", content="bar") + yield ThreadItemUpdatedEvent( + item_id=workflow_item.id, + update=WorkflowTaskAdded(task=task, task_index=0), + ) + + workflow_item.workflow.tasks.append(task) + yield ThreadItemDoneEvent(item=workflow_item) + + with make_server(responder) as server: + events = await server.process_streaming( + ThreadsCreateReq( + params=ThreadCreateParams( + input=UserMessageInput( + content=[UserMessageTextContent(text="Hello")], + attachments=[], + inference_options=InferenceOptions(), + ) + ) + ) + ) + + thread = next(e.thread for e in events if e.type == "thread.created") + workflow_done_event = next( + e + for e in events + if isinstance(e, ThreadItemDoneEvent) and isinstance(e.item, WorkflowItem) + ) + workflow_done_item = cast(WorkflowItem, workflow_done_event.item) + assert len(workflow_done_item.workflow.tasks) == 1 + assert workflow_done_item.workflow.tasks[0].title == "foo" + + stored = await server.store.load_item( + thread.id, workflow_done_item.id, DEFAULT_CONTEXT + ) + assert isinstance(stored, WorkflowItem) + stored_workflow = cast(WorkflowItem, stored) + assert len(stored_workflow.workflow.tasks) == 1 + assert stored_workflow.workflow.tasks[0].title == "foo" + + async def test_flows_context_to_responder(): responder_context = None add_feedback_context = None From 37d96d202746fc2f3f5e3f116f2ccc77dd1dff8e Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 9 Jan 2026 10:34:14 -0800 Subject: [PATCH 4/5] lint --- tests/test_chatkit_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_chatkit_server.py b/tests/test_chatkit_server.py index 6d593ca..f6797fa 100644 --- a/tests/test_chatkit_server.py +++ b/tests/test_chatkit_server.py @@ -45,7 +45,6 @@ LockedStatus, Page, ProgressUpdateEvent, - ThoughtTask, Thread, ThreadAddClientToolOutputParams, ThreadAddUserMessageParams, From c4f347230be4eeacbfb1b3f406abe4dc0860cbef Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 9 Jan 2026 10:35:58 -0800 Subject: [PATCH 5/5] 1.5.1 patch version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d07742b..90e5de8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-chatkit" -version = "1.5.0" +version = "1.5.1" description = "A ChatKit backend SDK." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 94a0c7b..47b19a5 100644 --- a/uv.lock +++ b/uv.lock @@ -819,7 +819,7 @@ wheels = [ [[package]] name = "openai-chatkit" -version = "1.5.0" +version = "1.5.1" source = { virtual = "." } dependencies = [ { name = "jinja2" },