AI product engineer · Travel & hospitality (external)
Detect a flight change with an unfinished baggage update
Check flights, bags and other linked services before telling a traveller that a booking change is complete.
Key takeaways (3)
- A changed flight can leave the journey incomplete even when the connection, pickup and bag have moved.
- Check each linked service against the new journey before announcing that the whole change is complete.
- Freeze further changes after partial success and give the travel desk the references already created.
A traveller asks to move a trip to the afternoon. Changing the flight also means updating its linked services, including baggage. If one update is lost, the traveller can end up with parts of the trip still tied to the old flights.
You will test that partial change in the fictional Horizon Travel service. A deliberately altered adapter loses the bag update so you can inspect how the booking is marked for repair and further changes are stopped.
In this offline exercise, a deliberately altered adapter acknowledges the afternoon change but does not retain the bag update. The existing reconciliation detects it and freezes the booking:
injected_bag_write_loss preflight_resolved True first pending effects 1 unresolved ['BG-1'] retry journey_frozen retry_effects 0 changes 1
This is an injected failure in fictional service code, not an observed airline or model failure. A valid plan describes what should happen. The post-write check reads what actually happened.
Separate refusal from a change awaiting repair
Horizon Travel is fictional. Its fixture contract requires checking linked services before a change and reconciling them afterwards. This is an exercise policy, not an airline’s operational contract.
The example distinguishes three outcomes:
| Outcome | What has happened | What the conversation may report |
|---|---|---|
| Blocked before the write | A linked partner could not be checked; no change was applied | Nothing changed; give the desk reference |
| Complete after reconciliation | The flight changed and every linked service fits the new journey | Report the changed flight and each service’s state |
| Pending after the write | The flight changed but a required linked service no longer fits | Name the changed parts and unresolved service; freeze further changes |
The cost of freezing the whole booking is that the assistant cannot offer another automatic change while parking awaits repair. That restriction protects the state the desk must reconcile. A new repair operation would need its own authority and plan.
Get the public companion project. This tutorial uses that explicit source revision because travel was added after the site’s earlier companion revision. Its lock still pins Rasa Pro to 3.21.0.dev5.
git clone https://github.com/RasaHQ/rasa-community-resources.git travel-receipt-lab
cd travel-receipt-lab
git checkout f54ddf4e5d674e3e9175a7a469cf698fcfaf1295
cd examples/mantle-voice-travel-booking-gpt
From that project directory, run the fixture tests without a licence or provider keys:
- Any system
python3 -m unittest discover -s tests -v
The two named tests below cover the distinction: partial change and freeze, and unreadable partner refusal. To reproduce the selected receipt, run from its tests directory:
cd tests
python3 -m unittest -v \
test_journeys.ChangeTests.test_next_day_change_is_partial_and_freezes_the_booking \
test_journeys.ChangeTests.test_unreadable_partner_blocks_with_no_change \
test_journeys.ChangeTests.test_draft_must_match_the_segment_as_it_is_now \
test_journeys.ChangeTests.test_services_read_back_must_be_the_services_checked
cd ..
Actual offline output:
test_next_day_change_is_partial_and_freezes_the_booking (test_journeys.ChangeTests.test_next_day_change_is_partial_and_freezes_the_booking) ... ok
test_unreadable_partner_blocks_with_no_change (test_journeys.ChangeTests.test_unreadable_partner_blocks_with_no_change) ... ok
test_draft_must_match_the_segment_as_it_is_now (test_journeys.ChangeTests.test_draft_must_match_the_segment_as_it_is_now) ... ok
test_services_read_back_must_be_the_services_checked (test_journeys.ChangeTests.test_services_read_back_must_be_the_services_checked) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.001s
OK
Elapsed time will vary. These tests exercise the Python service. The uninstalled full suite skips its speech-engine tests when Rasa cannot be imported. Neither result is proof of model behaviour or a successful voice call.
Follow the parking cover through the date change
Both replacement options start from the same Lisbon-to-Boston segment. The afternoon flight remains within the parking cover. The next-day flight does not.
The journey planner and reconciliation code treats a timed pickup differently from fixed cover. A pickup can move by its offset from departure. Parking remains valid only if its end time covers the new arrival.
| Receipt item | Afternoon option | Next-day option | What reconciliation compares |
|---|---|---|---|
| Flight S3 | October 24, 15:30 departure | October 25, 11:15 departure | Pre-write identity and reported after-state |
| Connection S4 | Moved to October 24, 20:10 | Moved to October 25, 16:05 | Departure against arrival plus connection time |
| Pickup TR-1 | Moved to October 24, 12:45 | Moved to October 25, 08:30 | Pickup time against its departure offset |
| Bag BG-1 | Re-tagged for the October 24 flights | Re-tagged for the October 25 flights | Routing against both resulting flight identifiers |
| Parking PK-1 | Still valid through October 24, 23:59 | Unresolved; still ends October 24, 23:59 | Cover end against the resulting Denver arrival |
| Whole change | Succeeded | Pending and frozen | Every required receipt must reconcile |
These dates and identifiers are synthetic fixture values. The table summarises the actual offline results, not real provider responses.
Reconcile identifiers rather than counting successes
Moving three linked services does not outweigh one unresolved service. The commit function applies the plan, then derives completion from the receipt rows:
service.apply(booking, sid, option, items)
rows = service.reconcile(booking, items)
facts["change_receipt_reconciled"] = all(r["points_at_new_times"] for r in rows)
status, reason, effects = lab_outcome(facts)
The all-receipts comparison is all(...). It asks whether every resulting identifier fits the new journey. A provider’s readable status before the write does not answer that question.

The blocked branch has not applied a change. The frozen branch has. Keep that distinction in the result passed to the conversation.
Repeat the frozen draft yourself
Paste this complete exercise into the project directory. It uses the same fixture calls as the offline result above, without private logs or a speech service:
python3 - <<'PY'
from lib import journeys as j
for option in ("HZ4R8N-S3-A", "HZ4R8N-S3-B"):
service = j.JourneyService("public-exercise")
ready, memory = j.prepare_journey_change(
service, "HT-PAX-7204", "HZ4R8N", option)
first = j.apply_journey_change(service, "HT-PAX-7204", memory, "HZ4R8N")
retry = j.apply_journey_change(service, "HT-PAX-7204", memory, "HZ4R8N")
print(option, first["status"], first["reason"], first["effects"],
first["service_states"])
print("retry", retry["status"], retry["reason"], retry["effects"],
"change_count", len(service.changes))
if option.endswith("B"):
assert first["unresolved"] == ["PK-1"]
assert retry["reason"] == "journey_frozen" and retry["effects"] == 0
assert len(service.changes) == 1
PY
Actual output:
HZ4R8N-S3-A succeeded verified_fixture_receipt 1 {'S4': 'moved', 'TR-1': 'moved', 'BG-1': 're_tagged', 'PK-1': 'still_valid'}
retry blocked wrong_segment 0 change_count 1
HZ4R8N-S3-B pending partial_journey_change 1 {'S4': 'moved', 'TR-1': 'moved', 'BG-1': 're_tagged', 'PK-1': 'unresolved'}
retry blocked journey_frozen 0 change_count 1
The successful option’s old draft is also refused, because it no longer matches the segment. The pending option hits the booking freeze first. The assertion checks one recorded change, rather than inferring it from a polite refusal message.
Fail an update that looked valid before writing
The next-day parking mismatch is already visible in the plan. That fixture permits a partial change so the exercise can practise repair receipts; it is not a recommendation to commit a known incompatible plan. A production integration should refuse known incompatibilities before any write.
A different test is needed for a failure discovered afterwards. Both rows below use the afternoon option, whose plan has no unresolved item. The altered adapter calls the normal update, then deliberately restores the old bag routing. The planner cannot see that later injected write loss.
| Adapter | Plan resolved before writing | Receipt after writing | Repeat attempt |
|---|---|---|---|
| Normal fixture adapter | Yes | Succeeded | No new change |
| Deliberately loses bag update | Yes | Pending; bag unresolved | Frozen; no new change |
Run this separate exercise from the same project directory. No checked-out file is changed.
from lib import journeys as j
class LostBagUpdate(j.JourneyService):
def apply(self, booking, segment_id, option, items):
bag = next(item for item in items if item['kind'] == 'bag')
before = list(booking['services'][bag['id']]['routing'])
super().apply(booking, segment_id, option, items)
# Deliberately simulate one adapter failing to persist its acknowledged update.
booking['services'][bag['id']]['routing'] = before
for label, factory in (('normal', j.JourneyService), ('injected_bag_write_loss', LostBagUpdate)):
service = factory('after-write-exercise')
ready, memory = j.prepare_journey_change(service, 'HT-PAX-7204', 'HZ4R8N', 'HZ4R8N-S3-A')
booking = service.data['bookings']['HZ4R8N']
option = next(x for x in booking['options']['S3'] if x['id'] == 'HZ4R8N-S3-A')
plan = service.plan(booking, 'S3', option)
assert all(x['state'] != 'unresolved' and x['status_feed'] == 'ok' for x in plan)
first = j.apply_journey_change(service, 'HT-PAX-7204', memory, 'HZ4R8N')
retry = j.apply_journey_change(service, 'HT-PAX-7204', memory, 'HZ4R8N')
print(label, 'preflight_resolved', True, 'first', first['status'], 'effects', first['effects'], 'unresolved', first.get('unresolved', []), 'retry', retry['reason'], 'retry_effects', retry['effects'], 'changes', len(service.changes))
assert len(service.changes) == 1
if label != 'normal':
assert first['status'] == 'pending' and retry['reason'] == 'journey_frozen' and retry['effects'] == 0
Actual unedited output, recorded on 8 October 2026:
normal preflight_resolved True first succeeded effects 1 unresolved [] retry wrong_segment retry_effects 0 changes 1
injected_bag_write_loss preflight_resolved True first pending effects 1 unresolved ['BG-1'] retry journey_frozen retry_effects 0 changes 1
The normal row is a positive control. The injected row shows why preflight checks cannot replace reconciliation. The freeze prevents a blind second change; it does not undo the first or perform the desk’s repair.
Carry the existing references to the desk
The Rasa adapter retains the structured result. This excerpt from skills/change_travel_booking/tools.py shows the call and receipt path:
result = hj.apply_journey_change(_service(), _traveller(context), _memory(context), booking,
_caller_texts(context))
if result["status"] in ("succeeded", "pending") or result.get("reason") == "connection_not_checked":
_set_memory(context, {})
await _send_receipt(context, "apply_journey_change", result)
return ToolResult(llm_response=result)
Clearing the draft does not undo the service changes. The freeze lives in the service, where a repeat call can check it. The adapter also sends the receipt when the project’s receipt flag is enabled.
Show the partial-change receipt
The offline formatter names both the changes and the unresolved parking. This is generated text, not a recorded voice exchange:
Your Lisbon to Boston flight is now H Z 2 1 5 at 11:15 AM, October 25. Your Denver connection is now H Z 4 3 8 at 4:05 PM, your Lisbon hotel pickup moves to 8:30 AM and your checked bag is re-tagged. But your Denver parking was not changed: it still ends October 24 at 11:59 PM, so I've frozen this booking for the travel desk, reference T D, 4 6 7, 0 6 9.The same result carries change reference HC-549272 and desk reference TD-467069. Both are synthetic. Keep them with the per-service states when designing a repair handoff.
On 8 October 2026, the full suite also ran with the actual Rasa Pro 3.21.0.dev5 SDK installed, without skips. Project validation accepted the skills and tools. From the selected project, the commands are:
uv run --locked python -m unittest discover -s tests -v
make validate
Actual output excerpts:
Ran 45 tests in 0.007s
OK
validate_project: ok
These are offline SDK checks. No licensed training, model or speech-provider call ran for these receipts.
For an optional microphone check, use only this project with Python 3.12. Its setup files provide the lock, environment template and Make targets:
uv sync --locked --python 3.12
make env
# Fill .env locally with RASA_LICENSE, OPENAI_API_KEY,
# DEEPGRAM_API_KEY and RIME_API_KEY. Do not commit it.
make validate
make train
make inspect
The pinned integrations.yml selects GPT, Deepgram transcription and Rime speech. Provider calls are billed. No live provider run was made for the evidence here.
Try asking to move booking H Z four R eight N’s Lisbon-to-Boston return to October 25. Inspect the tool result and answer two acceptance questions: does the caller hear that parking still ends October 24? After asking to repeat the change, is the booking still frozen with no second recorded change? Those observations would extend the offline evidence to your sampled call.