Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "deixic-python",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "6878323651fdb771b8cbf76b08597b930938ce36",
"sourceSha": "d7caa65707209a2fce2e960f30618360e4ec7c74",
"destinationRepository": "dx-corp/deixic-python",
"priorProjectedBase": "1a71e41b4d48efd28d8b3efdccaf714758784873",
"priorProjectedBase": "adf4c06109779a8df27bf8808499194a28e8d3b1",
"definitionDigest": "b278f71b9fce8cc8d4cfbe22eff3e054ef8a20caa2142f7f6a3d54005ee55119",
"toolDigest": "16fcaea2f0e7e35de09bcd8e515d915f65cdf1eb13033eaf2845f60eaa9e0a4f",
"contentDigest": "2d91766f58c81be8dd6c7a6181bdfa231c7c73a38195603bde98eba5c948b7cc",
"toolDigest": "9f3ee17d06ac34337873740d0109c3274bff8315d312227eb55383c5d169116d",
"contentDigest": "0c5d9293e58524e921228e3ce055669ec6d19e995c722a7612bbf303634650a6",
"publicationEligible": true
}
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,35 @@

## Unreleased

- Report missing or removed model routes during setup instead of declaring the
workspace accessible, and preserve plain-text account briefs when account
names contain structured-output instruction text. Keep rejected credential
refreshes from poisoning later requests.
- Add validated, source-linked account briefs, missing-data handling, progress
output, explicit owner-authorized approvals/denials, and receipt outcomes.
- Verify clean package consumers over binary HTTP with killed workers, storage
failures, lost responses and Python/TypeScript checkpoint interoperability.
- Atomically publish and sync private checkpoint files before submission.

## [0.1.5](https://github.com/dx-corp/mono/compare/sdk/deixic/python/v0.1.4...sdk/deixic/python/v0.1.5) (2026-09-19)


### Bug Fixes

* **sdk:** reject truncated Python watch streams ([#9714](https://github.com/dx-corp/mono/issues/9714)) ([76c1086](https://github.com/dx-corp/mono/commit/76c1086609a13612cda320622d48b9a5e9eb4f43))


### Tests

* **sdk:** cover empty and errored stream endings ([#9716](https://github.com/dx-corp/mono/issues/9716)) ([3482008](https://github.com/dx-corp/mono/commit/3482008754041a7adc9fcaa61eb96f75f8333589))

## [0.1.4](https://github.com/dx-corp/mono/compare/sdk/deixic/python/v0.1.3...sdk/deixic/python/v0.1.4) (2026-09-19)


### Bug Fixes

* **sdk:** harden setup and credential recovery ([#9671](https://github.com/dx-corp/mono/issues/9671)) ([e70ec36](https://github.com/dx-corp/mono/commit/e70ec364d1ebb2669b104ab6e8aee2a2aee57986))

## [0.1.3](https://github.com/dx-corp/mono/compare/sdk/deixic/python/v0.1.2...sdk/deixic/python/v0.1.3) (2026-09-19)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "deixic-sdk"
version = "0.1.3"
version = "0.1.5"
description = "Python SDK for submitting Deixic tasks, following progress, and resolving approvals"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
17 changes: 9 additions & 8 deletions src/deixic/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,6 @@ def check(self, credential: Credential) -> Credential:
raise authentication_error(
"credential refresh removed its declared workspace_id"
)
self._declared_organization = self._declared_organization or bool(
organization_id
)
self._declared_workspace = self._declared_workspace or bool(workspace_id)

subject = _clean(credential.subject)
if self._subject and not subject:
raise authentication_error(
Expand All @@ -103,9 +98,6 @@ def check(self, credential: Credential) -> Credential:
raise authentication_error(
"credential refresh changed the authenticated subject"
)
if subject:
self._subject = subject

scopes = frozenset(
scope.strip() for scope in credential.scopes if scope.strip()
)
Expand All @@ -117,6 +109,15 @@ def check(self, credential: Credential) -> Credential:
raise authentication_error(
"credential refresh changed its declared OAuth scopes"
)
# Commit identity metadata only after every claim has passed. A rejected
# refresh must not make the original credential look as if it removed a
# tenant declaration, subject, or scope on the next request.
self._declared_organization = self._declared_organization or bool(
organization_id
)
self._declared_workspace = self._declared_workspace or bool(workspace_id)
if subject:
self._subject = subject
if scopes and self._scopes is None:
self._scopes = scopes
return credential
Expand Down
34 changes: 29 additions & 5 deletions src/deixic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,16 @@ def _decode_stream(
) -> Iterator[MessageT]:
def messages() -> Iterator[MessageT]:
buffer = bytearray()
ended = False
try:
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
if ended:
raise DeixicError(
"Deixic stream returned bytes after its end envelope",
kind="protocol",
)
buffer.extend(chunk)
while len(buffer) >= 5:
flags = buffer[0]
Expand All @@ -429,17 +435,31 @@ def messages() -> Iterator[MessageT]:
del buffer[: 5 + length]
if flags == 0x02:
try:
document = (
json.loads(payload.decode("utf-8")) if payload else {}
)
document = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DeixicError(
"Deixic stream returned an invalid end envelope",
kind="protocol",
) from exc
if isinstance(document, dict) and document.get("error"):
if not isinstance(document, dict):
raise DeixicError(
"Deixic stream returned a non-object end envelope",
kind="protocol",
)
if "error" in document:
if not isinstance(document["error"], dict):
raise DeixicError(
"Deixic stream returned an invalid error envelope",
kind="protocol",
)
raise connect_stream_error(payload, response.headers)
return
if buffer:
raise DeixicError(
"Deixic stream returned bytes after its end envelope",
kind="protocol",
)
ended = True
break
if flags != 0:
raise DeixicError(
f"Deixic stream used unsupported envelope flags: {flags}",
Expand All @@ -458,6 +478,10 @@ def messages() -> Iterator[MessageT]:
raise DeixicError(
"Deixic stream ended with an incomplete frame", kind="protocol"
)
if not ended:
raise DeixicError(
"Deixic stream ended without an end envelope", kind="protocol"
)
except DeixicError:
raise
except Exception as exc:
Expand Down
4 changes: 3 additions & 1 deletion src/deixic/examples/account_brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ def progress(event):
)

outcome = task.wait(timeout=args.timeout, on_event=progress)
structured = FORMAT_INSTRUCTION in task.checkpoint()["body"]
# The instruction is an application-owned suffix. An account name
# containing the same text must not change the saved request mode.
structured = task.checkpoint()["body"].endswith(FORMAT_INSTRUCTION)
brief = None
if outcome.status == "completed" and structured:
try:
Expand Down
5 changes: 4 additions & 1 deletion src/deixic/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ def check_setup(self, *, channel_id: str) -> SetupCheck:
),
None,
)
unavailable_model = selected is not None and not selected.ready
# Platform deliberately omits both the catalog and default target when
# managed inference is unavailable. An explicit selection that no
# longer appears in the catalog is unavailable for the same reason.
unavailable_model = selected is None or not selected.ready
return SetupCheck(
"needs_attention" if missing or unavailable_model else "accessible",
channel_id,
Expand Down
136 changes: 97 additions & 39 deletions src/memory/v1/memory_pb2.py

Large diffs are not rendered by default.

29 changes: 26 additions & 3 deletions tests/test_account_brief_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from console.v1 import console_pb2 as pb

from test_account_brief import command, configuration, invocation, owner
from deixic.examples.account_brief_result import parse_account_brief
from deixic.examples.account_brief_result import FORMAT_INSTRUCTION, parse_account_brief

BRIEF = dict(
schemaVersion="deixic.account-brief.v1",
Expand All @@ -27,13 +27,15 @@
)


def start(path, environment, language="python", structured=True):
def start(
path, environment, language="python", structured=True, account="Example account"
):
return command(
[
"start",
str(path),
"--account",
"Example account",
account,
"--trigger",
"crm-event-001",
*(["--structured"] if structured else []),
Expand Down Expand Up @@ -120,6 +122,27 @@ def test_structured_application_result_and_action_outcomes(tmp_path, language, f
assert not errors


@pytest.mark.parametrize("language", ["python", "typescript"])
def test_account_text_cannot_change_the_saved_result_mode(tmp_path, language):
account = "Example account " + FORMAT_INSTRUCTION + " Holdings"
with owner(state=dict(body="Plain-text account brief")) as (
url,
operations,
submissions,
errors,
):
env = configuration(url)
path = tmp_path / "brief.json"
assert start(path, env, language, structured=False, account=account)[0] == 0
code, result = command(["resume", str(path)], env, language)
assert code == 0
assert result["status"] == "completed"
assert result["body"] == "Plain-text account brief"
assert result.get("brief") is None
assert len(operations) == len(submissions) == 1
assert not errors


@pytest.mark.parametrize("language", ["python", "typescript"])
@pytest.mark.parametrize(
"decision", ["approve", "deny", "unavailable_history", "wrong_request", "forbidden"]
Expand Down
109 changes: 100 additions & 9 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,21 @@ def send(
class RotatingCredentials:
can_refresh = True

def __init__(self, refreshed: Credential) -> None:
def __init__(
self, refreshed: Credential, original: Credential | None = None
) -> None:
self.refreshed = refreshed
self.refreshes = 0

def get_credential(self) -> Credential:
return Credential(
self.original = original or Credential(
access_token="old-token",
subject="subject-a",
organization_id="org-a",
workspace_id="workspace-a",
scopes=("console:write",),
)
self.refreshes = 0

def get_credential(self) -> Credential:
return self.original

def refresh_credential(self, current: Credential) -> Credential:
self.refreshes += 1
Expand Down Expand Up @@ -245,6 +248,41 @@ def test_refresh_rejects_subject_change_before_replay() -> None:
assert first.closed is True


def test_rejected_refresh_does_not_poison_later_credential_reads() -> None:
first = FakeResponse(
401, b'{"code":"unauthenticated"}', {"content-type": "application/json"}
)
transport = FakeTransport(
[first, response(console_pb2.GetOperatingThreadResponse(replay_cursor=4))]
)
credentials = RotatingCredentials(
original=Credential(
access_token="old-token",
subject="subject-a",
scopes=("console:write",),
),
refreshed=Credential(
access_token="new-token",
subject="subject-a",
organization_id="org-a",
scopes=("console:read",),
),
)
client = Deixic(
credential_provider=credentials,
organization_id="org-a",
workspace_id="workspace-a",
transport=transport,
)

with pytest.raises(DeixicError, match="changed its declared OAuth scopes"):
client.threads.get(channel_id="company")

result = client.threads.get(channel_id="company")
assert result.replay_cursor == 4
assert len(transport.requests) == 2


def test_watch_decodes_bounded_connect_stream_frames() -> None:
first = console_pb2.WatchOperatingThreadResponse(next_cursor=4)
second = console_pb2.WatchOperatingThreadResponse(
Expand Down Expand Up @@ -358,6 +396,63 @@ def test_watch_rejects_malformed_end_envelope_as_protocol_error() -> None:
assert stream_response.closed is True


def _frame(payload: bytes, *, flags: int = 0) -> bytes:
return bytes([flags]) + struct.pack(">I", len(payload)) + payload


@pytest.mark.parametrize(
"chunks",
[
(_frame(console_pb2.WatchOperatingThreadResponse(next_cursor=4).SerializeToString()),),
(_frame(b"", flags=2),),
(_frame(b"[]", flags=2),),
(_frame(b'{"error":{}}', flags=2),),
(_frame(b"{}", flags=2) + b"extra",),
(_frame(b"{}", flags=2), b"extra"),
],
ids=["missing-end", "empty-end", "non-object-end", "empty-error", "trailing-buffer", "trailing-chunk"],
)
def test_watch_rejects_truncated_or_corrupt_stream_end(chunks: tuple[bytes, ...]) -> None:
stream_response = FakeResponse(
200,
headers={"content-type": "application/connect+proto"},
chunks=chunks,
)
client = Deixic(
api_key="sdk-test-key",
organization_id="org-a",
workspace_id="workspace-a",
transport=FakeTransport([stream_response]),
)

with pytest.raises(DeixicError) as caught:
list(client.events.watch(channel_id="company", after_cursor=0))

assert caught.value.kind == "protocol"
assert stream_response.closed is True


def test_watch_preserves_server_error_from_end_envelope() -> None:
stream_response = FakeResponse(
200,
headers={"content-type": "application/connect+proto", "x-request-id": "request-1"},
chunks=(_frame(b'{"error":{"code":"unavailable","message":"retry"}}', flags=2),),
)
client = Deixic(
api_key="sdk-test-key",
organization_id="org-a",
workspace_id="ws-fixture",
transport=FakeTransport([stream_response]),
)

with pytest.raises(DeixicError) as caught:
list(client.events.watch(channel_id="company", after_cursor=0))

assert caught.value.kind == "unavailable"
assert caught.value.request_id == "request-1"
assert stream_response.closed is True


@pytest.mark.parametrize(
"base_url",
[
Expand Down Expand Up @@ -395,10 +490,6 @@ def test_client_reports_invalid_auth_configuration_as_typed_validation(
assert caught.value.kind == "validation"


def _frame(payload: bytes, *, flags: int = 0) -> bytes:
return bytes([flags]) + struct.pack(">I", len(payload)) + payload


def test_coding_submission_preserves_explicit_acceptance_and_declares_kind() -> None:
transport = FakeTransport([FakeResponse(200, console_pb2.SubmitOperatingMessageResponse().SerializeToString())])
client = Deixic(api_key="test-key", organization_id="org-a", workspace_id="workspace-a", transport=transport)
Expand Down
Loading