Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
67af0b5
chore: project deixic-python from Mono b31318e06ac4
github-actions[bot] Sep 20, 2026
9d81948
chore: project deixic-python from Mono 9ffaa22bee2d
github-actions[bot] Sep 20, 2026
8a9179a
chore: project deixic-python from Mono 67f00d037a06
github-actions[bot] Sep 20, 2026
af42916
chore: project deixic-python from Mono 0274e34d132f
github-actions[bot] Sep 20, 2026
aa632f5
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 20, 2026
39f9ff2
chore: project deixic-python from Mono 8420a1f8c5fd
github-actions[bot] Sep 20, 2026
3a35c53
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 20, 2026
63c9311
chore: project deixic-python from Mono a2e8b231b208
Sep 20, 2026
bc4469a
chore: project deixic-python from Mono cc1e1c0a4251
Sep 20, 2026
ae5688d
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
bf49e72
chore: project deixic-python from Mono 9b559bc70e7d
Sep 21, 2026
bc436f1
chore: project deixic-python from Mono 78799bd5ae75
Sep 21, 2026
f71d41b
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
efb9463
chore: project deixic-python from Mono 9a003786d767
Sep 21, 2026
c6fab6c
chore: project deixic-python from Mono 511bd2305f5a
Sep 21, 2026
33766ff
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
8d1cb65
chore: project deixic-python from Mono ad7b9df0b712
Sep 21, 2026
c51a704
chore: project deixic-python from Mono 3a95acea6719
Sep 21, 2026
c9111df
chore: project deixic-python from Mono c16f3515c56a
Sep 21, 2026
cc235a2
chore: project deixic-python from Mono 4ab4845398fd
Sep 21, 2026
d79a858
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 22, 2026
0325b9f
chore: project deixic-python from Mono 43ad52f7bcc9
Sep 22, 2026
5df6234
chore: project deixic-python from Mono 2df44a8283c0
Sep 22, 2026
702c1fc
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 22, 2026
ac04f04
chore: project deixic-python from Mono f6e0aa99e756
Sep 22, 2026
084be5b
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 22, 2026
2b700b4
chore: project deixic-python from Mono 3457dcec675e
Sep 22, 2026
729b13d
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 22, 2026
84aa796
chore: project deixic-python from Mono 2102d45bdf4c
Sep 22, 2026
3534ced
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
4d7e85b
chore: project deixic-python from Mono 0135ea0655c8
Sep 23, 2026
0e89e4e
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
f5bc48b
chore: project deixic-python from Mono 7f322bbd03a5
Sep 23, 2026
22fd8c1
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
f9e5c98
chore: project deixic-python from Mono c3fe404d7c05
Sep 23, 2026
fa65638
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
4514f87
chore: project deixic-python from Mono 53a87ef3a96f
Sep 23, 2026
6216d13
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
b0692b8
chore: project deixic-python from Mono 0405bd95fc12
Sep 24, 2026
6f0ab97
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
baa1e61
chore: project deixic-python from Mono 916d407917e8
Sep 24, 2026
d14a597
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
d1bd3d8
chore: project deixic-python from Mono c27319825e1c
Sep 24, 2026
4b105bd
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
c1cdcad
chore: project deixic-python from Mono 274db22a3ad9
Sep 24, 2026
2ad811e
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
ecdbcfd
chore: project deixic-python from Mono a971433d12b4
Sep 24, 2026
4e1d557
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
5bb192d
chore: project deixic-python from Mono c97cbb033be2
Sep 24, 2026
d869810
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
fe5a3ce
chore: project deixic-python from Mono ddaee8a19ef0
Sep 24, 2026
9cac988
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 25, 2026
9f2959d
chore: project deixic-python from Mono 80416a066acc
Sep 25, 2026
37ec9bc
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 28, 2026
1e4eff6
chore: project deixic-python from Mono 4e3d31598d0e
Sep 28, 2026
c51aff2
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 28, 2026
46bd3bf
chore: project deixic-python from Mono 680e391abc7e
Sep 28, 2026
1b02c4a
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 28, 2026
66efea3
chore: project deixic-python from Mono 87ab9b7a2cfa
Sep 28, 2026
b471df3
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 29, 2026
f591111
chore: project deixic-python from Mono 9be4e39ad20d
Sep 29, 2026
76998d6
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 29, 2026
a102413
chore: project deixic-python from Mono 240fc63676d0
Sep 29, 2026
f93f535
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 29, 2026
d5e6a3d
chore: project deixic-python from Mono bc55890b975c
Sep 29, 2026
a378dee
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Oct 1, 2026
8ddb353
chore: project deixic-python from Mono 6edc57f941d2
Oct 1, 2026
9933581
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Oct 3, 2026
b81786f
chore: project deixic-python from Mono c91ec884dd13
Oct 3, 2026
bc36254
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Oct 4, 2026
069284a
chore: project deixic-python from Mono ec9e1d787745
Oct 4, 2026
f7f6620
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Oct 4, 2026
c0e460f
chore: project deixic-python from Mono 21f8bdad3c20
Oct 4, 2026
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
6 changes: 3 additions & 3 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": "ec9e1d787745e301ecb871186224dc34f6707147",
"sourceSha": "21f8bdad3c2009ea769db44ebdd743a09f7e1f45",
"destinationRepository": "dx-corp/deixic-python",
"priorProjectedBase": "f85ce38b4c7535b7c800af03996ecfea1c50894e",
"priorProjectedBase": "b696ff3e2db09dfadc8076258f5c2646292404a6",
"definitionDigest": "eedaf99642ef977b56b6e12ce151e1834670560f00294e89224f0628882937d9",
"toolDigest": "535e3cb77f0b59898e63a5f097fb3308564bafbc",
"contentDigest": "496279dc412b804f4aaa40f7b04f4c88f1a43990e2edbe5f9885f603b317d51d",
"contentDigest": "f381dd1578d25581206ff0c88037bd5adfe8bb981b529f93a59491b03e060584",
"publicationEligible": true
}
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,28 @@ IDs and decimal-string cursors. Their `channelId` identifies the public thread.
Python message types are available from `deixic.protocol`; TypeScript exports
public message types and schemas from the package root. Existing TypeScript
operating-type names are aliases of these public types.


## Brand voices

`voices.list()` reads active voice IDs and display metadata for this client's fixed workspace.
Guidance remains with the Settings owner. Select up to four IDs in order; the first leads.
Explicit selections use `SubmitVoicedTask`, which older servers refuse before acceptance.
Ordinary submissions continue to use `SubmitTask` and the workspace default.

Tasks prepared with an explicit selection use the shared `deixic.task.v2` checkpoint.
It retains the IDs and tone adjustments across restart and explicit replay with the same
idempotency key. Existing `deixic.task.v1` checkpoints retain their original request.
Do not change an uncertain request's selection; start a separate logical request instead.

```python
from deixic import protocol as pb
catalog = deixic.voices.list()
task = deixic.tasks.prepare(
channel_id="company", body="Draft the update", idempotency_key="update-1",
voice_selection=pb.VoiceSelection(mode=pb.VOICE_MODE_BRAND_VOICE,
voice_ids=["brand", "customer-care"], tone_adjustments=[pb.VOICE_TONE_WARMER]),
on_checkpoint=save_checkpoint,
)
task.submit()
```
39 changes: 39 additions & 0 deletions src/deixic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(
self.threads = ThreadsClient(self)
self.events = EventsClient(self)
self.messages = MessagesClient(self)
self.voices = VoicesClient(self)
self.controls = ControlsClient(self)
self.receipts = ReceiptsClient(self)
from .tasks import TasksClient
Expand Down Expand Up @@ -279,6 +280,7 @@ def send(
reference_task_ids: tuple[str, ...] | list[str] = (),
project_resource_id: str | None = None,
coding_acceptance: public_pb2.CodingContract | None = None,
voice_selection: public_pb2.VoiceSelection | None = None,
) -> public_pb2.SubmitTaskResponse:
request = public_pb2.SubmitTaskRequest(
scope=self._client._scope(),
Expand All @@ -294,6 +296,17 @@ def send(
request.project_resource_id = _required(
project_resource_id, "project_resource_id"
)
if voice_selection is not None:
# Snapshot before credential callbacks; a 401 replays identical bytes.
selection = normalize_voice_selection(voice_selection)
result = self._client._unary(
"SubmitVoicedTask",
public_pb2.SubmitVoicedTaskRequest(task=request, voice_selection=selection),
public_pb2.SubmitVoicedTaskResponse,
)
if not result.HasField("result"):
raise DeixicError("Voiced submission omitted its result", kind="protocol")
return result.result
return self._client._unary(
"SubmitTask",
request,
Expand Down Expand Up @@ -523,3 +536,29 @@ def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> None:
raise validation_error(
f"{field} must be an integer between {minimum} and {maximum}"
)


class VoicesClient:
def __init__(self, client: Deixic) -> None:
self._client = client

def list(self) -> public_pb2.GetVoiceCatalogResponse:
result = self._client._unary(
"GetVoiceCatalog", public_pb2.GetVoiceCatalogRequest(scope=self._client._scope()),
public_pb2.GetVoiceCatalogResponse,
)
if result.scope != self._client._scope():
raise DeixicError("Voice catalog belongs to a different workspace", kind="protocol")
return result


def normalize_voice_selection(value: public_pb2.VoiceSelection) -> public_pb2.VoiceSelection:
if not isinstance(value, public_pb2.VoiceSelection):
raise validation_error("Invalid voice selection")
ids, tones = list(value.voice_ids), list(value.tone_adjustments)
if (value.mode not in (0, 1, 2, 3) or len(ids) > 4 or len(ids) != len(set(ids))
or any(not item or item != item.strip() or len(item) > 128 for item in ids)
or (not ids if value.mode == 2 else bool(ids))
or len(tones) > 3 or any(tone not in (1, 2, 3) for tone in tones)):
raise validation_error("Invalid voice IDs or tone adjustments")
return public_pb2.VoiceSelection(mode=value.mode, voice_ids=ids, tone_adjustments=sorted(set(tones)))
55 changes: 48 additions & 7 deletions src/deixic/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from copy import deepcopy
import math
import random
import time
Expand All @@ -22,8 +23,8 @@
MAX_CURSOR = 2**63 - 1


class TaskCheckpoint(TypedDict):
schema: Literal["deixic.task.v1"]
class _TaskCheckpointBase(TypedDict):
schema: Literal["deixic.task.v1", "deixic.task.v2"]
organizationId: str
workspaceId: str
baseUrl: str
Expand All @@ -37,6 +38,10 @@ class TaskCheckpoint(TypedDict):
cursor: str


class TaskCheckpoint(_TaskCheckpointBase, total=False):
voiceSelection: dict[str, Any]


@dataclass(frozen=True)
class TaskResult:
status: Literal[
Expand Down Expand Up @@ -90,6 +95,7 @@ def prepare(
body: str,
idempotency_key: str,
project_resource_id: str = "",
voice_selection: pb.VoiceSelection | None = None,
on_checkpoint: Callable[[TaskCheckpoint], None] | None = None,
) -> Task:
state: TaskCheckpoint = dict(
Expand All @@ -108,6 +114,9 @@ def prepare(
sequence="0",
cursor="0",
)
if voice_selection is not None:
state["schema"] = "deixic.task.v2"
state["voiceSelection"] = _voice_checkpoint(voice_selection)
task = Task(self._client, state, on_checkpoint)
task._save()
return task
Expand All @@ -119,6 +128,7 @@ def start(
body: str,
idempotency_key: str,
project_resource_id: str = "",
voice_selection: pb.VoiceSelection | None = None,
on_checkpoint: Callable[[TaskCheckpoint], None] | None = None,
) -> Task:
"""Prepare and submit once. Use on_checkpoint for restart recovery."""
Expand All @@ -127,6 +137,7 @@ def start(
body=body,
idempotency_key=idempotency_key,
project_resource_id=project_resource_id,
voice_selection=voice_selection,
on_checkpoint=on_checkpoint,
).submit()

Expand All @@ -137,12 +148,17 @@ def resume(
on_checkpoint: Callable[[TaskCheckpoint], None] | None = None,
) -> Task:
# A checkpoint supplies coordinates, never authorization or completion.
keys = TaskCheckpoint.__required_keys__
keys = {"schema", "organizationId", "workspaceId", "baseUrl", "channelId", "body",
"idempotencyKey", "projectResourceId", "submission", "turnId", "sequence", "cursor"}
if isinstance(checkpoint, Mapping) and checkpoint.get("schema") == "deixic.task.v2":
keys.add("voiceSelection")
if not isinstance(checkpoint, Mapping) or set(checkpoint) != keys:
raise validation_error("Invalid task checkpoint fields")
state = dict(checkpoint)
if state["schema"] != "deixic.task.v1":
state = deepcopy(dict(checkpoint))
if state["schema"] not in ("deixic.task.v1", "deixic.task.v2"):
raise validation_error("Unsupported task checkpoint schema")
if state["schema"] == "deixic.task.v2":
state["voiceSelection"] = _voice_checkpoint(_voice_from_checkpoint(state["voiceSelection"]))
if (state["organizationId"], state["workspaceId"], state["baseUrl"]) != (
self._client.organization_id,
self._client.workspace_id,
Expand Down Expand Up @@ -241,15 +257,15 @@ def __init__(
if on_checkpoint is not None and not callable(on_checkpoint):
raise validation_error("on_checkpoint must be callable")
self._client = client
self._state = cast(TaskCheckpoint, dict(state))
self._state = cast(TaskCheckpoint, deepcopy(state))
self._on_checkpoint = on_checkpoint
self._event: pb.TaskEvent | None = None
self._submitting = False
self._observing = False

def checkpoint(self) -> TaskCheckpoint:
"""JSON-safe coordinates and original request; excludes credentials."""
return cast(TaskCheckpoint, dict(self._state))
return cast(TaskCheckpoint, deepcopy(self._state))

def _save(self) -> None:
if self._on_checkpoint:
Expand All @@ -276,6 +292,8 @@ def _submit(self, required: str) -> Task:
body=self._state["body"],
idempotency_key=self._state["idempotencyKey"],
project_resource_id=self._state["projectResourceId"] or None,
**({"voice_selection": _voice_from_checkpoint(self._state["voiceSelection"])}
if self._state["schema"] == "deixic.task.v2" else {}),
)
turn = accepted.accepted_turn
if (
Expand Down Expand Up @@ -654,3 +672,26 @@ def _validate_page(page: pb.ListEventsResponse, cursor: int) -> None:
if event.cursor in records and records[event.cursor] != encoded:
raise _protocol("Different events reused the same cursor")
records[event.cursor] = encoded


def _voice_checkpoint(value: pb.VoiceSelection) -> dict[str, Any]:
from .client import normalize_voice_selection
selected = normalize_voice_selection(value)
return {"mode": selected.mode, "voiceIds": list(selected.voice_ids),
"toneAdjustments": list(selected.tone_adjustments)}


def _voice_from_checkpoint(value: Any) -> pb.VoiceSelection:
if (not isinstance(value, dict) or set(value) != {"mode", "voiceIds", "toneAdjustments"}
or type(value["mode"]) is not int or not isinstance(value["voiceIds"], list)
or not isinstance(value["toneAdjustments"], list)
or len(value["voiceIds"]) > 4 or len(value["toneAdjustments"]) > 3
or any(not isinstance(item, str) for item in value["voiceIds"])
or any(type(item) is not int for item in value["toneAdjustments"])):
raise validation_error("Invalid checkpoint voice selection")
try:
from .client import normalize_voice_selection
return normalize_voice_selection(pb.VoiceSelection(
mode=value["mode"], voice_ids=value["voiceIds"], tone_adjustments=value["toneAdjustments"]))
except (TypeError, ValueError, OverflowError) as exc:
raise validation_error("Invalid checkpoint voice selection") from exc
66 changes: 41 additions & 25 deletions src/deixicpublic/v1/sdk_pb2.py

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,51 @@ def test_unsupported_offset_fails_before_transport():
with pytest.raises(DeixicError):
client.threads.get(channel_id="thread-a", offset=1)
assert not transport.requests


def test_explicit_blend_is_frozen_before_credentials_and_uses_distinct_rpc():
selected = public_pb2.VoiceSelection(mode=2, voice_ids=["lead", "support"], tone_adjustments=[3, 1])
class MutatingCredentials:
can_refresh = False
def get_credential(self):
selected.voice_ids.reverse()
return Credential(access_token="fixture")
transport = FakeTransport([response(public_pb2.SubmitVoicedTaskResponse(result=public_pb2.SubmitTaskResponse(accepted_turn=public_pb2.TaskTurn(turn_id="voiced", sequence=1))))])
sdk = Deixic(organization_id="org-a", workspace_id="workspace-a", credential_provider=MutatingCredentials(), transport=transport)
assert sdk.messages.send(channel_id="company", body="draft", idempotency_key="voice-key", voice_selection=selected).accepted_turn.turn_id == "voiced"
request = public_pb2.SubmitVoicedTaskRequest.FromString(transport.requests[0]["body"])
assert transport.requests[0]["url"].endswith("/SubmitVoicedTask")
assert list(request.voice_selection.voice_ids) == ["lead", "support"]
assert list(request.voice_selection.tone_adjustments) == [1, 3]
assert request.task.idempotency_key == "voice-key"


def test_voice_catalog_scope_and_selection_bounds():
transport = FakeTransport([response(public_pb2.GetVoiceCatalogResponse(scope=public_pb2.Scope(organization_id="other", workspace_id="workspace-a")))])
sdk = Deixic(organization_id="org-a", workspace_id="workspace-a", api_key="fixture", transport=transport)
with pytest.raises(DeixicError, match="different workspace"):
sdk.voices.list()
for selected in [public_pb2.VoiceSelection(mode=2), public_pb2.VoiceSelection(mode=2, voice_ids=["a", "a"]), public_pb2.VoiceSelection(mode=3, voice_ids=["a"]), public_pb2.VoiceSelection(mode=99)]:
with pytest.raises(DeixicError):
sdk.messages.send(channel_id="company", body="draft", idempotency_key="key", voice_selection=selected)
assert len(transport.requests) == 1


def test_voiced_authentication_replays_identical_bytes_and_old_server_never_falls_back():
selected = public_pb2.VoiceSelection(mode=2, voice_ids=["lead", "support"])
class MutatingRefresh(RotatingCredentials):
def refresh_credential(self, current):
selected.voice_ids.reverse()
return super().refresh_credential(current)
transport = FakeTransport([FakeResponse(401, b'{"code":"unauthenticated"}'), response(public_pb2.SubmitVoicedTaskResponse(result=public_pb2.SubmitTaskResponse(accepted_turn=public_pb2.TaskTurn(turn_id="turn", sequence=1))))])
credentials = MutatingRefresh(Credential(access_token="new", subject="subject-a", organization_id="org-a", workspace_id="workspace-a", scopes=("console:write",)))
sdk = Deixic(organization_id="org-a", workspace_id="workspace-a", credential_provider=credentials, transport=transport)
sdk.messages.send(channel_id="company", body="draft", idempotency_key="key", voice_selection=selected)
assert transport.requests[0]["body"] == transport.requests[1]["body"]
assert all(request["url"].endswith("/SubmitVoicedTask") for request in transport.requests)
old = FakeTransport([FakeResponse(404, b'{"code":"unimplemented"}')])
sdk = Deixic(organization_id="org-a", workspace_id="workspace-a", api_key="fixture", transport=old)
with pytest.raises(DeixicError):
sdk.messages.send(channel_id="company", body="draft", idempotency_key="key", voice_selection=public_pb2.VoiceSelection(mode=3))
assert len(old.requests) == 1
assert old.requests[0]["url"].endswith("/SubmitVoicedTask")
30 changes: 30 additions & 0 deletions tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,33 @@ def test_invalid_acceptance_stays_unacknowledged_and_requires_explicit_replay():
assert error.value.kind == "protocol"
assert task.result().status == "unacknowledged"
assert len(transport.requests) == 1


def test_v2_checkpoint_retains_blend_through_loss_restart_and_mutation():
class LostResponse(FakeTransport):
def send(self, *args, **kwargs):
result = super().send(*args, **kwargs)
if len(self.requests) == 1:
raise ConnectionError("lost response")
return result
transport = LostResponse([response(pb.SubmitVoicedTaskResponse(result=accepted())), response(pb.SubmitVoicedTaskResponse(result=accepted()))])
selected = pb.VoiceSelection(mode=2, voice_ids=["lead", "support"], tone_adjustments=[3])
checkpoints = []
def save(value):
checkpoints.append(json.loads(json.dumps(value)))
value["voiceSelection"]["voiceIds"].reverse()
task = prepared(client(transport), voice_selection=selected, on_checkpoint=save)
selected.voice_ids.reverse()
task.checkpoint()["voiceSelection"]["voiceIds"].reverse()
with pytest.raises(DeixicError):
task.submit()
saved = checkpoints[-1]
resumed = client(transport).tasks.resume(json.loads(json.dumps(saved)))
saved["voiceSelection"]["voiceIds"].reverse()
resumed.replay()
assert resumed.checkpoint()["schema"] == "deixic.task.v2"
assert transport.requests[0]["body"] == transport.requests[1]["body"]
assert list(pb.SubmitVoicedTaskRequest.FromString(transport.requests[1]["body"]).voice_selection.voice_ids) == ["lead", "support"]
for corrupted in [dict(resumed.checkpoint(), schema="deixic.task.v1"), dict(resumed.checkpoint(), voiceSelection={"mode": 2, "voiceIds": ["a", "a"], "toneAdjustments": []})]:
with pytest.raises(DeixicError):
client(transport).tasks.resume(corrupted)