fix: safely update named notes
This commit is contained in:
@@ -417,7 +417,13 @@ class AgentLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
'"text":"Список покупок: бумажные полотенца, чеснок"'
|
||||
'}}],"reset_context":false}'
|
||||
)
|
||||
ai_client = ScriptedAIClient([bad_delete, corrected_update])
|
||||
list_notes = (
|
||||
'{"tool_calls":[{"name":"list_notes","arguments":{}}],'
|
||||
'"reset_context":false}'
|
||||
)
|
||||
ai_client = ScriptedAIClient(
|
||||
[bad_delete, list_notes, corrected_update]
|
||||
)
|
||||
update, context, message = self.make_update_and_context(
|
||||
storage,
|
||||
ai_client,
|
||||
@@ -432,7 +438,7 @@ class AgentLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
notes = storage.list_notes(42)
|
||||
conversation = storage.list_conversation_messages(42, 100)
|
||||
|
||||
self.assertEqual(len(ai_client.calls), 2)
|
||||
self.assertEqual(len(ai_client.calls), 3)
|
||||
repair_messages = ai_client.calls[1]["messages"]
|
||||
assert isinstance(repair_messages, list)
|
||||
repair_envelope = json.loads(repair_messages[-1]["content"])
|
||||
@@ -453,6 +459,168 @@ class AgentLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
f"Заметка #{note_id} обновлена.",
|
||||
)
|
||||
|
||||
async def test_add_to_named_note_resolves_id_before_updating(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
storage = create_storage(Path(directory) / "assistant.sqlite3")
|
||||
obsolete_id = storage.add_note(42, "Старая заметка")
|
||||
storage.delete_note(42, obsolete_id)
|
||||
note_id = storage.add_note(
|
||||
42,
|
||||
"Список покупок: бумажные полотенца",
|
||||
)
|
||||
guessed_update = (
|
||||
'{"tool_calls":[{"name":"update_note","arguments":{"id":'
|
||||
f"{obsolete_id},"
|
||||
'"text":"Список покупок: сахар"'
|
||||
'}}],"reset_context":false}'
|
||||
)
|
||||
list_notes = (
|
||||
'{"tool_calls":[{"name":"list_notes","arguments":{}}],'
|
||||
'"reset_context":false}'
|
||||
)
|
||||
resolved_update = (
|
||||
'{"tool_calls":[{"name":"update_note","arguments":{"id":'
|
||||
f"{note_id},"
|
||||
'"text":"Список покупок: бумажные полотенца, сахар"'
|
||||
'}}],"reset_context":false}'
|
||||
)
|
||||
ai_client = ScriptedAIClient(
|
||||
[guessed_update, list_notes, resolved_update]
|
||||
)
|
||||
update, context, _message = self.make_update_and_context(
|
||||
storage,
|
||||
ai_client,
|
||||
)
|
||||
|
||||
await run_agent_prompt(
|
||||
update,
|
||||
context,
|
||||
"Добавь сахар в список покупок",
|
||||
)
|
||||
|
||||
notes = storage.list_notes(42)
|
||||
conversation = storage.list_conversation_messages(42, 100)
|
||||
|
||||
self.assertEqual(len(ai_client.calls), 3)
|
||||
repair_messages = ai_client.calls[1]["messages"]
|
||||
assert isinstance(repair_messages, list)
|
||||
repair_envelope = json.loads(repair_messages[-1]["content"])
|
||||
self.assertEqual(repair_envelope["kind"], "protocol_error")
|
||||
self.assertIn("Сначала вызови только list_notes", repair_envelope["error"])
|
||||
update_messages = ai_client.calls[2]["messages"]
|
||||
assert isinstance(update_messages, list)
|
||||
list_envelope = json.loads(update_messages[-1]["content"])
|
||||
self.assertEqual(list_envelope["kind"], "tool_results")
|
||||
self.assertEqual(
|
||||
list_envelope["tool_results"][0]["result"]["items"][0]["id"],
|
||||
note_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
notes,
|
||||
[
|
||||
{
|
||||
"id": note_id,
|
||||
"text": "Список покупок: бумажные полотенца, сахар",
|
||||
"created_at": notes[0]["created_at"],
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
conversation[-1]["content"],
|
||||
f"Заметка #{note_id} обновлена.",
|
||||
)
|
||||
|
||||
async def test_add_to_missing_named_note_creates_it(self) -> None:
|
||||
guessed_update = (
|
||||
'{"tool_calls":[{"name":"update_note","arguments":{"id":1,'
|
||||
'"text":"Список покупок: сахар"}}],"reset_context":false}'
|
||||
)
|
||||
list_notes = (
|
||||
'{"tool_calls":[{"name":"list_notes","arguments":{}}],'
|
||||
'"reset_context":false}'
|
||||
)
|
||||
create_note = (
|
||||
'{"tool_calls":[{"name":"create_note","arguments":'
|
||||
'{"text":"Список покупок: сахар"}}],"reset_context":false}'
|
||||
)
|
||||
ai_client = ScriptedAIClient(
|
||||
[guessed_update, list_notes, create_note]
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
storage = create_storage(Path(directory) / "assistant.sqlite3")
|
||||
update, context, _message = self.make_update_and_context(
|
||||
storage,
|
||||
ai_client,
|
||||
)
|
||||
|
||||
await run_agent_prompt(
|
||||
update,
|
||||
context,
|
||||
"Добавь сахар в список покупок",
|
||||
)
|
||||
|
||||
notes = storage.list_notes(42)
|
||||
conversation = storage.list_conversation_messages(42, 100)
|
||||
|
||||
self.assertEqual(len(ai_client.calls), 3)
|
||||
list_messages = ai_client.calls[2]["messages"]
|
||||
assert isinstance(list_messages, list)
|
||||
list_envelope = json.loads(list_messages[-1]["content"])
|
||||
self.assertEqual(list_envelope["kind"], "tool_results")
|
||||
self.assertEqual(
|
||||
list_envelope["tool_results"][0]["result"]["items"],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(notes), 1)
|
||||
self.assertEqual(notes[0]["text"], "Список покупок: сахар")
|
||||
self.assertEqual(
|
||||
conversation[-1]["content"],
|
||||
f"Заметка #{notes[0]['id']} сохранена.",
|
||||
)
|
||||
|
||||
async def test_failed_mutation_is_returned_to_model_for_recovery(self) -> None:
|
||||
missing_id = 99
|
||||
failed_update = (
|
||||
'{"tool_calls":[{"name":"update_note","arguments":{"id":'
|
||||
f"{missing_id},"
|
||||
'"text":"Новый текст"'
|
||||
'}}],"reset_context":false}'
|
||||
)
|
||||
ai_client = ScriptedAIClient(
|
||||
[
|
||||
failed_update,
|
||||
'{"final":"Заметка #99 не найдена.","reset_context":false}',
|
||||
]
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
storage = create_storage(Path(directory) / "assistant.sqlite3")
|
||||
update, context, message = self.make_update_and_context(
|
||||
storage,
|
||||
ai_client,
|
||||
)
|
||||
|
||||
await run_agent_prompt(
|
||||
update,
|
||||
context,
|
||||
"Замени текст заметки #99 на «Новый текст»",
|
||||
)
|
||||
|
||||
self.assertEqual(len(ai_client.calls), 2)
|
||||
recovery_messages = ai_client.calls[1]["messages"]
|
||||
assert isinstance(recovery_messages, list)
|
||||
tool_envelope = json.loads(recovery_messages[-1]["content"])
|
||||
self.assertEqual(tool_envelope["kind"], "tool_results")
|
||||
self.assertFalse(
|
||||
tool_envelope["tool_results"][0]["result"]["ok"]
|
||||
)
|
||||
message.reply_text.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
message.reply_text.await_args.args[0],
|
||||
r"Заметка \#99 не найдена\.",
|
||||
)
|
||||
|
||||
async def test_reschedule_cannot_be_misread_as_cancellation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
storage = create_storage(Path(directory) / "assistant.sqlite3")
|
||||
@@ -614,6 +782,8 @@ class AgentToolContractTests(unittest.TestCase):
|
||||
"result.items, а не по неполному reference_data",
|
||||
"Не добавляй благодарности",
|
||||
"одним предложением обычного текста только о результате",
|
||||
"сначала обязательно вызови list_notes",
|
||||
"Если после list_notes подходящей заметки нет, создай ее",
|
||||
):
|
||||
with self.subTest(clause=clause):
|
||||
self.assertIn(clause, prompt)
|
||||
|
||||
Reference in New Issue
Block a user