Skip to content

[4.41/C8] Unified History API (bot.history.user/thread/channel), to_prompt_entries, transcripts deprecation #197

Description

@patrick-chinchill

Summary

Upstream makes chat.history the canonical history API with three scopes: user (the old chat.transcripts), thread (list/collect/append) and channel (list_messages/list_threads/list_threads_with_messages). transcripts/TranscriptEntry stay as deprecated aliases, AI read tools route through history, and max_per_user=False disables count eviction. Python has only chat.transcripts.

Upstream changes

  • 169788b6 feat(chat): introduce unified History API with user, thread, and chan… (#592) — chat@4.39.0:
    • Adds packages/chat/src/history/{index,user,thread,channel,to-prompt,resolve-adapter,types}.ts.
    • ChatConfig.history = {thread?, user?}; thread precedence history.thread ?? threadHistory ?? messageHistory; user config merged {...transcripts, ...history.user}; identity history.user.identity ?? identity, construction throws without one.
    • chat.transcripts returns history.user; TranscriptsApiImpl re-exports UserHistoryApiImpl; HistoryEntry/UserHistoryEntry, toPromptEntries. AI fetchMessages/fetchChannelMessages/listThreads call chat.history.*; unregistered prefixes and missing capabilities throw.
  • 056d8830 feat(history): support uncapped per-user retention (#904) — chat@4.41.0 — maxPerUser?: number | false on both UserHistoryConfig and TranscriptsConfig. false means no cap.

Current Python behavior

  • grep -rnE 'HistoryApi|HistoryConfig|to_prompt_entries|PromptEntry|HistoryEntry|UserHistory' src tests only hits the unrelated ThreadHistoryConfig (thread_history.py:35).
  • src/chat_sdk/chat.py:379-393 builds _ThreadHistoryCache (:2688, append/get_messages) from thread_history/message_history and TranscriptsApiImpl from config.transcripts + config.identity (raising ValueError without identity); :446-456 is the transcripts property.
  • src/chat_sdk/types.py:1732 has ChatConfig.thread_history: dict (:1721 deprecated message_history), :1739 has transcripts, and :1713 has identity. TranscriptsConfig.max_per_user: int | None is at :2062.
  • src/chat_sdk/transcripts.py:98 maps None to 200; False passes through to append_to_list, uncapped only by accident (state/memory.py:215 if max_length and …, state/redis.py:265 max_length or 0, state/postgres.py:386 if max_length:).
  • src/chat_sdk/ai/tools.py: fetch_messages (:631) calls thread.adapter.fetch_messages with no SDK-cache fallback for persisting adapters; fetch_channel_messages (:682) and list_threads (:761) resolve the adapter by hand and raise Adapter "x" does not support ….
  • ChatInstance (types.py:1748) exposes transcripts but not history.

Scope

  • New package src/chat_sdk/history/:
    • resolve_adapter.py: require_adapter(get_adapter, id, scope) raises ChatError with upstream's text. persists_history(adapter) returns persist_thread_history or persist_message_history.
    • thread.py ThreadHistoryApiImpl: list falls back to the cache only when the page is empty, next_cursor is None, no cursor was requested, a cache exists and the adapter persists history (window: newest N backward, oldest N forward); collect is an async generator (page size max(1, min(100, remaining)), stops on an empty page even with a cursor, limit == 0 yields nothing, cache path yields oldest N, only when nothing was yielded); append raises without a cache.
    • channel.py ChannelHistoryApiImpl: list_messages uses fetch_channel_messages, else the channel-keyed cache for persisting adapters, else a capability error; list_threads raises if absent; list_threads_with_messages(max_threads=5, messages_per_thread=None, cursor=None) fetches in batches of 4 via history.thread.list.
    • user.py UserHistoryApiImpl: move the body of transcripts.py here. Its append error text becomes history.user.append: options.userKey is required when appending an AppendInput.
    • to_prompt.py to_prompt_entries(entries) -> list[PromptEntry], where PromptEntry is a TypedDict(role, content).
    • __init__.py HistoryApiImpl(adapter_resolver, cache=None, user=None). Accessing .user raises upstream's "chat.history.user is not configured …" error.
  • transcripts.py becomes a deprecated alias module: TranscriptsApiImpl = UserHistoryApiImpl (same object).
  • types.py: HistoryConfig(thread: dict | None, user: UserHistoryConfig | None); UserHistoryConfig(identity, max_per_user, retention, store_formatted) with every field defaulting to None ("unset"); max_per_user: int | Literal[False] | None on both configs; aliases HistoryEntry = TranscriptEntry, UserHistoryEntry, UserHistoryRole; Protocols HistoryApi/ThreadHistoryApi/ChannelHistoryApi, UserHistoryApi = TranscriptsApi; ChatConfig.history, ChatInstance.history.
  • chat.py: thread-cache precedence with is not None; field-by-field user-config merge; resolve identity (history.user.identity, else identity); if none, raise at construction with upstream's message ("ChatConfig requires an identity resolver when user history (or legacy transcripts) is configured …"), keeping today's ValueError class; self._history takes a resolver closing over self._adapters (sees later registrations); add the history property; transcripts returns history.user.
  • Route the AI tools fetch_messages, fetch_channel_messages and list_threads through chat.history. Keep the existing translation of ChatNotImplementedError to ChatError. Update the error-text assertions in tests/test_ai_tools.py:479-570.
  • Export the new names from chat_sdk/__init__.py. Keep the state key prefixes transcripts:user: and msg-history: byte-identical.

Out of scope

Porting notes

  • Field-by-field merge. Dataclass fields always exist, so {**transcripts, **history.user} can't tell "unset" from "default". With all-None UserHistoryConfig defaults, merge per field: u.f if u is not None and u.f is not None else t.f (legacy store_formatted=False is a fine fallback).
  • max_per_user=False. bool subclasses int and is falsy: normalize first, None if v is False else (v if v is not None else 200), and pass max_length=None; never if not v / isinstance(v, int) before the is False check. max_per_user=0 is uncapped via backend truthiness, as upstream — document, don't change.
  • Cursor/limit. The list fallback gate uses options.cursor is None / next_cursor is None (upstream === undefined); collect's loop exit is upstream's falsy check (if not result.next_cursor or not result.messages: break), so "" also ends it. limit is None checks; limit=0 yields nothing.
  • Async. collect never pre-schedules a page, so an early break leaves no fetch pending. list_threads_with_messages uses asyncio.gather per slice of 4, preserving order.
  • "Absent" capability. Upstream tests if (adapter.fetchChannelMessages). Python BaseAdapter subclasses inherit stubs that raise ChatNotImplementedError (no in-repo adapter subclasses it). Recommended: treat a method as absent when getattr returns None or it is the unoverridden BaseAdapter stub, so a persisting adapter still gets the channel-cache fallback exactly as upstream; if a call still raises ChatNotImplementedError, re-raise as the capability error (no cache retry).

Tests

New upstream files under packages/chat/src/history/. #185 adds TARGET_MAPPING rows (history/user → tests/test_transcripts.py for now; the others → files this PR creates). Create tests/test_history_{thread,channel,to_prompt,user}.py and update those rows to match.

  • thread.test.ts (15) and channel.test.ts (8): port every it() verbatim — all are new, e.g. "list does not substitute the cache on a continuation page", "collect stops when a page is empty even if the adapter echoes a cursor", "listThreadsWithMessages bounds per-thread fetch concurrency". verify_test_fidelity.py --report-target ([4.41/P0] Fidelity tooling for the 4.41 wave: single pin constant, SHA pin, it.each expansion, map new core test files #185) enumerates any still missing.
  • to-prompt.test.ts: "maps transcript entries to prompt entries preserving order", "skips entries with empty text".
  • user.test.ts: the renamed transcripts suite (25 exact name matches today). Move those cases from tests/test_transcripts.py to tests/test_history_user.py, re-pointed at history.user, and add the it.each "retains $expected entries with maxPerUser=$maxPerUser" for False → 205 and default → 200.

Mapped existing files:

  • transcripts.test.ts now contains only "re-exports UserHistoryApiImpl under the legacy name". Assert TranscriptsApiImpl is UserHistoryApiImpl.
  • transcripts-wiring.test.ts: add "throws at construction when history.user is set without identity", "chat.transcripts getter throws when user history was not configured", "chat.history.user returns the API instance when configured via history.user", and the it.each "merges legacy maxPerUser=%s under history.user" (50, False). The existing test_chattranscripts_getter_throws_when_transcripts_was_not_configured (:104) fuzzy-absorbs "chat.history.user getter throws when user history was not configured"; retarget it to chat.history.user under that exact name.

ai/index.test.ts (mapped): "fetchChannelMessages throws when the adapter does not support it" keeps does not support fetching channel messages and adds fetch_messages not-called; "listThreads throws when the adapter does not support it" now matches does not implement listThreads.

Acceptance criteria

  • The full validation command from CLAUDE.md passes (ruff check, ruff format --check, audit_test_quality, verify_test_fidelity, pytest).
  • docs/UPSTREAM_SYNC.md records the dataclass merge semantics, max_per_user=0 being uncapped (parity), and the BaseAdapter-stub / ChatNotImplementedError capability rule.
  • CHANGELOG entry under "Unreleased (4.41 wave)": chat.history (additive; transcripts/TranscriptEntry deprecated but working); max_per_user=False; changed AI-tool error text; AI reads return SDK-cached history for persisting adapters (e.g. Telegram/WhatsApp); unknown adapter prefixes now raise.
  • chat.transcripts is chat.history.user when configured. Existing state keys are unchanged.

Dependencies

Blocked by #195, because the read tools must keep the scope guard ahead of history calls. Blocks #198 and #203.

Metadata

  • Effort: L (about 1,000–1,400 LOC incl. tests; user-scope tests already exist)
  • Consumer impact: low. Additive; transcripts keeps working; AI-tool users see new error strings. None for Slack/Teams streaming.
  • Suggested branch: sync/4.41-c8

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions