From 06e51878bfef0b14abf6aa5b403df32ac43b0e09 Mon Sep 17 00:00:00 2001 From: paullizer Date: Tue, 18 Aug 2026 17:53:26 -0400 Subject: [PATCH] Deliver File Sync prompt context to the first workflow task Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_workflow_runner.py | 45 ++- .../WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md | 206 ++++++++++ docs/explanation/fixes/index.md | 1 + docs/explanation/release_notes.md | 16 + .../test_workflow_file_sync_prompt_context.py | 366 ++++++++++++++++++ .../test_workflow_task_sequence.py | 33 +- 7 files changed, 661 insertions(+), 8 deletions(-) create mode 100644 docs/explanation/fixes/WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md create mode 100644 functional_tests/test_workflow_file_sync_prompt_context.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 6abbbcb7..0c3bc709 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.225" +VERSION = "0.250.226" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 1ee221e2..4c3d09af 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -246,6 +246,7 @@ WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' WORKFLOW_RUN_CANCELLED_MESSAGE = 'Workflow cancellation was requested.' WORKFLOW_TASK_CONTEXT_MAX_CHARS = 12000 +WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS = 8000 class WorkflowRunCancelledError(BaseException): @@ -6615,7 +6616,9 @@ def _prepare_workflow_search_context( if resolved_action.get('target_mode') != DOCUMENT_ACTION_TARGET_MODE_RECENT and not document_ids: return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None} - query = str(workflow.get('task_prompt') or '').strip() + # Prefer the task's own instructions. task_prompt also carries injected File Sync context and + # previous-task output, which would otherwise become part of the search query itself. + query = str(workflow.get('task_search_query') or workflow.get('task_prompt') or '').strip() if not query: return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None} @@ -6995,6 +6998,21 @@ def _execute_workflow_file_sync(workflow, run_id, trigger_source): } +def _truncate_workflow_file_sync_context(value, max_chars=WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS): + """Bound the File Sync context block so a large sync cannot dominate the prompt.""" + normalized = str(value or '').strip() + if len(normalized) <= max_chars: + return normalized + + head_length = max_chars // 2 + tail_length = max_chars - head_length + return ( + f'{normalized[:head_length].rstrip()}\n\n' + '[File Sync context truncated]\n\n' + f'{normalized[-tail_length:].lstrip()}' + ) + + def _format_workflow_file_sync_context(file_sync_result): if not isinstance(file_sync_result, dict) or not file_sync_result.get('enabled'): return '' @@ -7027,7 +7045,9 @@ def _format_workflow_file_sync_context(file_sync_result): ) if len(changed_documents) > 50: lines.append(f'Additional changed documents omitted from prompt context: {len(changed_documents) - 50}') - return '\n'.join(lines) + # Truncate here rather than at the injection site so the conversation transcript and the + # prompt the model actually receives stay identical. + return _truncate_workflow_file_sync_context('\n'.join(lines)) def _apply_file_sync_changed_documents_to_action(action_config, changed_document_ids, group_ids, public_workspace_ids): @@ -7057,6 +7077,12 @@ def _apply_file_sync_context_to_workflow(workflow, file_sync_result): file_sync_context = _format_workflow_file_sync_context(file_sync_result) if file_sync_context: prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip() + # Task-based workflows overwrite task_prompt per task, so the context has to travel on + # its own key for _build_workflow_task_execution_workflow() to inject it. + prepared_workflow['file_sync_prompt_context'] = file_sync_context + # Keep an un-augmented query source for the legacy no-tasks path, whose document search + # would otherwise use the whole changed-document manifest as its search query. + prepared_workflow.setdefault('task_search_query', str(workflow.get('task_prompt') or '').strip()) config = _get_workflow_file_sync_config(workflow) changed_document_ids = list(file_sync_result.get('changed_document_ids') or []) @@ -9103,17 +9129,26 @@ def _resolve_workflow_task_document_action(workflow, task, include_document_acti return {'type': DOCUMENT_ACTION_TYPE_NONE} -def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', include_document_action=False): +def _build_workflow_task_execution_workflow( + workflow, + task, + previous_reply='', + include_document_action=False, + include_file_sync_context=False, +): task = task if isinstance(task, dict) else {} prepared_workflow = dict(workflow or {}) task_instructions = str(task.get('instructions') or '').strip() + # Captured before any context blocks are appended so document search queries stay scoped to + # what this task actually asks for. + task_search_query = task_instructions file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip() task_document_action = _resolve_workflow_task_document_action( workflow, task, include_document_action=include_document_action, ) - if include_document_action and file_sync_context: + if include_file_sync_context and file_sync_context: task_instructions = ( f'{task_instructions}\n\n' '[Workflow input context]\n' @@ -9129,6 +9164,7 @@ def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', i ).strip() prepared_workflow['task_prompt'] = task_instructions + prepared_workflow['task_search_query'] = task_search_query prepared_workflow['active_task'] = { 'id': str(task.get('id') or '').strip(), 'name': str(task.get('name') or '').strip(), @@ -9541,6 +9577,7 @@ def raise_if_cancelled(): task, previous_reply=previous_reply, include_document_action=task_index == 0, + include_file_sync_context=task_index == 0, ) attempt_workflow, runner_audit = _resolve_workflow_task_runner( prepared_workflow, diff --git a/docs/explanation/fixes/WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md b/docs/explanation/fixes/WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md new file mode 100644 index 00000000..04b37b28 --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md @@ -0,0 +1,206 @@ +# Workflow File Sync Prompt Context Fix + +**Fixed in version: 0.250.226** +**Issue:** [microsoft/simplechat#1285](https://github.com/microsoft/simplechat/issues/1285) + +## Issue + +File Sync's **prompt context** — the block that tells a workflow *what actually changed* — never +reached the model in any task-based workflow. Since the workflow builder always creates at least +one task, that was effectively every workflow. + +The failure was silent and actively misleading: the context **was** written into the +conversation's user message, so the run transcript displayed the changed-document list as though +the model had received it. In practice the model was given only the raw task instructions and +typically replied that it had no information about any documents. + +### What File Sync is supposed to do + +Before a workflow runs, File Sync scans the configured sources and hands the workflow two separate +things: + +1. **Prompt context** — built by `_format_workflow_file_sync_context()`: the sync counts + (`Scanned / Created / Updated / Unchanged / Skipped / Failed`) and a numbered list of each new + or changed document (`relative_path`, `action`, `document_id`, `source_name`). This is what + makes instructions like *"summarize the documents that changed since the last run"* work. +2. **Document targets** — when **Use changed documents** is enabled and the document action is + Analyze, the action is repointed at exactly the changed document ids. + +Item 2 worked. Item 1 did not. + +### Who was affected + +- **Monitor File Sync Changes** workflows — an entire trigger type whose purpose is reacting to + what changed — unless they happened to use Analyze with **Use changed documents**. +- Any workflow using `Search` or `No document action` got nothing at all from File Sync. +- Any workflow with **Use changed documents** disabled got nothing. + +## Root Cause + +`_apply_file_sync_context_to_workflow()` appended the context to the **workflow-level** +`task_prompt`. `_build_workflow_task_execution_workflow()` expected it on a **different key**, then +overwrote `task_prompt` with the task's own instructions: + +```python +file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip() # always '' +if include_document_action and file_sync_context: # never fired + task_instructions = f'{task_instructions}\n\n[Workflow input context]\n{file_sync_context}' +... +prepared_workflow['task_prompt'] = task_instructions # discarded the appended context +``` + +`file_sync_prompt_context` was read in exactly one place and written nowhere. The producer line was +added in `79148c84` ("Add stepped workflow builder and task sequences") alongside the consumer: + +```diff + if file_sync_context: + prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip() ++ prepared_workflow['file_sync_prompt_context'] = file_sync_context +``` + +It was later lost in a merge resolution on a long-lived branch (the +`fix/1031-tabular-row-orchestration-scale` / PR #1145 lineage). The consumer survived; the producer +did not. + +### Why no test caught it + +`functional_tests/test_workflow_task_sequence.py` hand-injected `file_sync_prompt_context` directly +into the workflow dict and then asserted that `[Workflow input context]` appeared in the dispatched +prompt. It exercised the consumer with a value production never supplied, so it passed while the +feature was broken. + +## Technical Details + +### Files Modified + +| File | Change | +|---|---| +| `application/single_app/functions_workflow_runner.py` | Restored the producer, bounded the context, gave the search query a clean source, made the first-task gate explicit | +| `application/single_app/config.py` | `VERSION` `0.250.225` → `0.250.226` | +| `functional_tests/test_workflow_file_sync_prompt_context.py` | **New** producer-to-consumer coverage | +| `functional_tests/test_workflow_task_sequence.py` | Builds the context through the real producer instead of injecting the key | + +### Code Changes + +**1. Restored the producer.** + +```python +if file_sync_context: + prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip() + prepared_workflow['file_sync_prompt_context'] = file_sync_context + prepared_workflow.setdefault('task_search_query', str(workflow.get('task_prompt') or '').strip()) +``` + +**2. Gave the document search query a clean source.** + +This was the blocking discovery. `_prepare_workflow_search_context()` used `workflow['task_prompt']` +**verbatim as the Azure AI Search query**, and that single `query` value fed all four search call +sites. Restoring the injection without this change would have turned a Search task's query into 50 +lines of file paths and sync counters. + +The prepared workflow now carries `task_search_query`, captured from the task's instructions +*before* any context blocks are appended: + +```python +task_search_query = task_instructions # captured before File Sync / previous-output injection +... +prepared_workflow['task_search_query'] = task_search_query +``` + +```python +query = str(workflow.get('task_search_query') or workflow.get('task_prompt') or '').strip() +``` + +The fallback to `task_prompt` means every caller without the key behaves exactly as before. + +This also corrects a related case: after per-task workspace documents shipped in +[#1284](https://github.com/microsoft/simplechat/pull/1284), a task other than the first can carry a +Search action, and its query previously included the entire previous-task reply. Search queries are +now scoped to the task's own instructions. + +**3. Made the first-task gate explicit.** + +The consumer was gated on `include_document_action`, which used to mean "task 1". After #1284 that +flag means "legacy record with no task-level document action", so it no longer expressed the +intent. A dedicated parameter now carries it: + +```python +def _build_workflow_task_execution_workflow( + workflow, task, previous_reply='', include_document_action=False, include_file_sync_context=False, +): +``` + +```python +prepared_workflow = _build_workflow_task_execution_workflow( + workflow, task, + previous_reply=previous_reply, + include_document_action=task_index == 0, + include_file_sync_context=task_index == 0, +) +``` + +Later tasks receive the information indirectly, through task one's response, which is already +chained forward as bounded context. + +**4. Bounded the context block.** + +`WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS = 8000` with head/tail truncation and a +`[File Sync context truncated]` marker, mirroring the existing treatment of previous-task output. +It is applied inside `_format_workflow_file_sync_context()` rather than at the injection site, so +the conversation transcript and the prompt the model receives stay identical — the mismatch between +those two is exactly what made this bug invisible. + +### Testing + +`functional_tests/test_workflow_file_sync_prompt_context.py` covers: + +- The producer publishes `file_sync_prompt_context`, and attaches nothing when File Sync is off. +- The first task's prompt carries `[Workflow input context]` and the changed-document list; later + tasks do not. +- The legacy no-tasks path still carries the context on `task_prompt`. +- Search queries use the task's own instructions, with no File Sync block and no previous-task + output, for both the task path and the legacy path. +- The truncation notice appears past the cap and not below it, including a realistic 50-document + sync with deeply nested paths. +- A run with nothing changed still tells the first task that nothing changed. +- **Use changed documents** targeting is unaffected. + +Both this file and the updated `test_workflow_task_sequence.py` were verified by mutation: removing +the restored producer line makes 4 of 8 and 1 of 10 tests fail respectively, so the regression +cannot silently return. + +## Validation + +### Before + +| Scenario | Result | +|---|---| +| File Sync workflow, `No document action`, instructions ask what changed | Model has no information about any documents | +| Conversation transcript | Shows the full changed-document list, implying the model received it | +| Search task query | The task instructions only, because the context never arrived | + +### After + +| Scenario | Result | +|---|---| +| File Sync workflow, `No document action`, instructions ask what changed | First task's prompt contains the sync counts and changed-document list | +| Conversation transcript | Matches what the model received | +| Search task query | The task's own instructions, free of File Sync context and previous-task output | +| Very large sync | Context bounded at 8000 characters with an explicit truncation notice | +| Nothing changed | First task is told "No new or changed synced documents were detected." | + +### Regression Testing + +The full `test_workflow*` suite was run against a clean `git archive` export of the base commit. +Both baseline and branch produce the same 21 pre-existing failures, and per-file output diffs +contain only traceback path differences. Route policy tests pass, and the personal and group +document picker harnesses from #1284 still pass. + +## Related + +- Issue: [microsoft/simplechat#1285](https://github.com/microsoft/simplechat/issues/1285) +- Preceding work: [#1284](https://github.com/microsoft/simplechat/pull/1284), which introduced + per-task workspace documents and changed the meaning of `include_document_action` +- Feature: `docs/explanation/features/WORKFLOW_PER_TASK_WORKSPACE_DOCUMENTS.md` +- Tests: `functional_tests/test_workflow_file_sync_prompt_context.py`, + `functional_tests/test_workflow_task_sequence.py` diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index 105b72a6..42ccf666 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -30,3 +30,4 @@ category: Version History - [Route Authentication Audit Findings Fix](ROUTE_AUTHENTICATION_AUDIT_FINDINGS_FIX.md) - [Cosmos Container Throughput Deployer Fix](COSMOS_CONTAINER_THROUGHPUT_DEPLOYER_FIX.md) - [Workflow Task Document Picker Fix](WORKFLOW_TASK_DOCUMENT_PICKER_FIX.md) +- [Workflow File Sync Prompt Context Fix](WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index b534d8dd..00dacd6a 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,22 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.226)** + +#### Bug Fixes + +* **File Sync Now Tells the Workflow What Changed** + * File Sync builds a summary of each run — the scan counts plus every new or changed document — but that summary never reached the model in any workflow that uses tasks, which is every workflow the builder creates. + * The failure was silent and misleading: the summary *was* written into the conversation, so the transcript showed the changed-document list as though the model had received it. In practice the model saw only the raw task instructions and usually replied that it knew nothing about any documents. + * This hit **Monitor File Sync Changes** workflows hardest, along with any workflow using Search or no document action, or with **Use changed documents** turned off. The first task in the sequence now receives the summary, and later tasks get it through the first task's response. + * The summary is also bounded now, with a clear truncation notice, so a very large sync cannot crowd out the actual instructions. + * (Ref: #1285, `functions_workflow_runner.py`, File Sync prompt context) + +* **Document Search Queries Are No Longer Diluted by Injected Context** + * A workflow's document search used the entire task prompt as its search query, including the File Sync summary and the previous task's full response. A search for "find the renewal clause" could end up querying 50 lines of file paths. + * Search queries now use the task's own instructions. Retrieved content and context still reach the model exactly as before — only the query is scoped. + * (Ref: #1285, `functions_workflow_runner.py`, workflow document search) + ### **(v0.250.225)** #### New Features diff --git a/functional_tests/test_workflow_file_sync_prompt_context.py b/functional_tests/test_workflow_file_sync_prompt_context.py new file mode 100644 index 00000000..4899edde --- /dev/null +++ b/functional_tests/test_workflow_file_sync_prompt_context.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +# test_workflow_file_sync_prompt_context.py +""" +Functional test for File Sync prompt context reaching the first workflow task. +Version: 0.250.226 +Implemented in: 0.250.226 + +This test ensures that: + 1. _apply_file_sync_context_to_workflow() publishes file_sync_prompt_context, the producer + that was lost in a merge and left the consumer reading a key nothing ever wrote. + 2. The first task's prompt carries the File Sync summary and the changed-document list, and + later tasks do not. + 3. The legacy no-tasks path still carries the context on task_prompt. + 4. Document search queries use the task's own instructions, never the injected File Sync + context or the previous task's output. + 5. The context block is bounded with a truncation notice. + +Refs microsoft/simplechat#1285 +""" + +import ast +import os +import sys +from pathlib import Path + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +RUNNER_FILE = APP_ROOT / "functions_workflow_runner.py" +MINIMUM_VERSION = "0.250.226" +FILE_SYNC_CONTEXT_MAX_CHARS = 8000 + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def load_functions(path: Path, function_names: set, namespace: dict) -> dict: + parsed = ast.parse(read_text(path), filename=str(path)) + selected_nodes = [ + node + for node in parsed.body + if isinstance(node, ast.FunctionDef) and node.name in function_names + ] + assert len(selected_nodes) == len(function_names), ( + f"Expected functions {sorted(function_names)} in {path.name}" + ) + exec(compile(ast.Module(body=selected_nodes, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def load_runner_helpers() -> dict: + namespace = { + "DOCUMENT_ACTION_TYPE_NONE": "none", + "DOCUMENT_ACTION_TYPE_ANALYZE": "analyze", + "WORKFLOW_TASK_CONTEXT_MAX_CHARS": 12000, + "WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS": FILE_SYNC_CONTEXT_MAX_CHARS, + "build_analyze_config": lambda action: {"enabled": (action or {}).get("type") == "analyze"}, + "_get_document_action_config": lambda source: dict( + (source or {}).get("document_action") or {"type": "none"} + ), + "_get_workflow_file_sync_config": lambda workflow: (workflow or {}).get("file_sync") or {}, + } + return load_functions( + RUNNER_FILE, + { + "_truncate_workflow_task_context", + "_truncate_workflow_file_sync_context", + "_format_workflow_file_sync_context", + "_apply_file_sync_changed_documents_to_action", + "_apply_file_sync_context_to_workflow", + "_resolve_workflow_task_document_action", + "_build_workflow_task_execution_workflow", + }, + namespace, + ) + + +def build_file_sync_result(changed_documents=None, enabled=True): + changed_documents = changed_documents if changed_documents is not None else [ + { + "document_id": "doc-1", + "relative_path": "contracts/acme-v3.pdf", + "action": "updated", + "source_name": "Contracts Share", + }, + { + "document_id": "doc-2", + "relative_path": "contracts/beta-v1.pdf", + "action": "created", + "source_name": "Contracts Share", + }, + ] + return { + "enabled": enabled, + "counts": { + "scanned": 12, + "created": 1, + "updated": 1, + "unchanged": 10, + "skipped": 0, + "failed": 0, + }, + "changed_documents": changed_documents, + "changed_document_ids": [document["document_id"] for document in changed_documents], + } + + +def build_workflow(tasks=None, document_action=None, use_changed_documents=False): + workflow = { + "id": "workflow-1", + "name": "Sync watcher", + "user_id": "user-1", + "runner_type": "model", + "task_prompt": "Summarize what changed.", + "document_action": document_action or {"type": "none"}, + "file_sync": { + "use_changed_documents": use_changed_documents, + "sources": [{"scope_type": "group", "scope_id": "group-1"}], + }, + } + if tasks is not None: + workflow["tasks"] = tasks + return workflow + + +def test_producer_publishes_file_sync_prompt_context() -> None: + """The producer lost in the merge must publish the key the task builder reads.""" + print("Testing File Sync prompt context producer...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + + prepared = apply_context(build_workflow(), build_file_sync_result()) + + assert "file_sync_prompt_context" in prepared, ( + "_apply_file_sync_context_to_workflow must publish file_sync_prompt_context; " + "_build_workflow_task_execution_workflow reads it and nothing else writes it." + ) + context = prepared["file_sync_prompt_context"] + assert "File Sync context for this workflow run" in context + assert "contracts/acme-v3.pdf" in context + assert "contracts/beta-v1.pdf" in context + assert "Scanned: 12" in context + + # Disabled File Sync must not attach anything. + untouched = apply_context(build_workflow(), {"enabled": False}) + assert "file_sync_prompt_context" not in untouched + print("PASS: File Sync prompt context producer") + + +def test_first_task_receives_context_and_later_tasks_do_not() -> None: + """Only the first task gets the File Sync block; later tasks inherit it via task one's reply.""" + print("Testing first-task-only File Sync injection...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + build_task_workflow = helpers["_build_workflow_task_execution_workflow"] + + tasks = [ + {"id": "one", "name": "One", "order": 1, "instructions": "List what changed."}, + {"id": "two", "name": "Two", "order": 2, "instructions": "Draft the summary."}, + ] + prepared = apply_context(build_workflow(tasks=tasks), build_file_sync_result()) + + first = build_task_workflow(prepared, tasks[0], include_file_sync_context=True) + second = build_task_workflow( + prepared, + tasks[1], + previous_reply="Task one output", + include_file_sync_context=False, + ) + + assert "[Workflow input context]" in first["task_prompt"] + assert "contracts/acme-v3.pdf" in first["task_prompt"] + assert first["task_prompt"].startswith("List what changed.") + + assert "[Workflow input context]" not in second["task_prompt"] + assert "contracts/acme-v3.pdf" not in second["task_prompt"] + assert "[Previous workflow task output]" in second["task_prompt"] + + runner_source = read_text(RUNNER_FILE) + assert "include_file_sync_context=task_index == 0," in runner_source, ( + "The sequence must gate File Sync context on the first task explicitly." + ) + assert "if include_document_action and file_sync_context:" not in runner_source, ( + "File Sync context must no longer piggyback on the document action fallback flag." + ) + print("PASS: first-task-only File Sync injection") + + +def test_search_query_ignores_injected_context() -> None: + """Document search must query the task's instructions, not the injected context.""" + print("Testing search query isolation...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + build_task_workflow = helpers["_build_workflow_task_execution_workflow"] + + tasks = [ + {"id": "one", "name": "One", "order": 1, "instructions": "Find the renewal clause."}, + {"id": "two", "name": "Two", "order": 2, "instructions": "Find the termination clause."}, + ] + prepared = apply_context(build_workflow(tasks=tasks), build_file_sync_result()) + + first = build_task_workflow(prepared, tasks[0], include_file_sync_context=True) + second = build_task_workflow( + prepared, + tasks[1], + previous_reply="A very long prior answer that would otherwise dilute the query.", + include_file_sync_context=False, + ) + + assert first["task_search_query"] == "Find the renewal clause." + assert "contracts/acme-v3.pdf" not in first["task_search_query"] + assert "[Workflow input context]" not in first["task_search_query"] + + assert second["task_search_query"] == "Find the termination clause." + assert "[Previous workflow task output]" not in second["task_search_query"] + assert "dilute the query" not in second["task_search_query"] + + # The legacy no-tasks path keeps an un-augmented query source too. + legacy = apply_context(build_workflow(), build_file_sync_result()) + assert legacy["task_search_query"] == "Summarize what changed." + assert "contracts/acme-v3.pdf" in legacy["task_prompt"] + + runner_source = read_text(RUNNER_FILE) + assert ( + "query = str(workflow.get('task_search_query') or workflow.get('task_prompt') or '').strip()" + in runner_source + ), "The workflow search query must prefer task_search_query and fall back to task_prompt." + print("PASS: search query isolation") + + +def test_legacy_no_task_workflows_keep_context_in_task_prompt() -> None: + """Workflows without tasks still dispatch with the context appended to task_prompt.""" + print("Testing legacy no-tasks context path...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + + prepared = apply_context(build_workflow(), build_file_sync_result()) + + assert prepared["task_prompt"].startswith("Summarize what changed.") + assert "File Sync context for this workflow run" in prepared["task_prompt"] + assert "contracts/beta-v1.pdf" in prepared["task_prompt"] + print("PASS: legacy no-tasks context path") + + +def test_no_changes_still_reaches_the_first_task() -> None: + """A run with nothing changed must still tell the first task that nothing changed.""" + print("Testing no-changes File Sync context...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + build_task_workflow = helpers["_build_workflow_task_execution_workflow"] + + tasks = [{"id": "one", "name": "One", "order": 1, "instructions": "Report on the sync."}] + prepared = apply_context( + build_workflow(tasks=tasks), + build_file_sync_result(changed_documents=[]), + ) + first = build_task_workflow(prepared, tasks[0], include_file_sync_context=True) + + assert "No new or changed synced documents were detected." in first["task_prompt"] + assert "[Workflow input context]" in first["task_prompt"] + print("PASS: no-changes File Sync context") + + +def test_context_is_bounded_with_a_truncation_notice() -> None: + """A very large sync must not let the context dominate the prompt.""" + print("Testing File Sync context truncation...") + helpers = load_runner_helpers() + format_context = helpers["_format_workflow_file_sync_context"] + truncate_context = helpers["_truncate_workflow_file_sync_context"] + + short_value = "a" * 100 + assert truncate_context(short_value) == short_value + assert "[File Sync context truncated]" not in truncate_context(short_value) + + long_value = "b" * (FILE_SYNC_CONTEXT_MAX_CHARS + 500) + truncated = truncate_context(long_value) + assert "[File Sync context truncated]" in truncated + assert len(truncated) < len(long_value) + + # A realistic oversized sync: 50 documents with very long relative paths. + oversized_documents = [ + { + "document_id": f"doc-{index}", + "relative_path": f"{'nested/' * 30}document-{index}.pdf", + "action": "updated", + "source_name": "Deep Share", + } + for index in range(50) + ] + context = format_context(build_file_sync_result(changed_documents=oversized_documents)) + assert "[File Sync context truncated]" in context + assert len(context) <= FILE_SYNC_CONTEXT_MAX_CHARS + len("\n\n[File Sync context truncated]\n\n") + print("PASS: File Sync context truncation") + + +def test_use_changed_documents_still_targets_analyze_tasks() -> None: + """Restoring the prompt context must not disturb the changed-document targeting.""" + print("Testing changed-document targeting is unaffected...") + helpers = load_runner_helpers() + apply_context = helpers["_apply_file_sync_context_to_workflow"] + + tasks = [ + { + "id": "one", + "name": "One", + "order": 1, + "instructions": "Analyze the changes.", + "document_action": {"type": "analyze", "document_ids": ["stale-doc"]}, + }, + ] + prepared = apply_context( + build_workflow( + tasks=tasks, + document_action={"type": "analyze", "document_ids": ["stale-doc"]}, + use_changed_documents=True, + ), + build_file_sync_result(), + ) + + assert prepared["document_action"]["document_ids"] == ["doc-1", "doc-2"] + assert prepared["tasks"][0]["document_action"]["document_ids"] == ["doc-1", "doc-2"] + assert prepared["tasks"][0]["document_action"]["active_group_ids"] == ["group-1"] + assert "file_sync_prompt_context" in prepared + print("PASS: changed-document targeting is unaffected") + + +def test_version_contract() -> None: + """The fix ships at or after its implementation version.""" + print("Testing version contract...") + assert_app_version_at_least(MINIMUM_VERSION) + print("PASS: version contract") + + +def run_tests() -> bool: + tests = [ + test_producer_publishes_file_sync_prompt_context, + test_first_task_receives_context_and_later_tasks_do_not, + test_search_query_ignores_injected_context, + test_legacy_no_task_workflows_keep_context_in_task_prompt, + test_no_changes_still_reaches_the_first_task, + test_context_is_bounded_with_a_truncation_notice, + test_use_changed_documents_still_targets_analyze_tasks, + test_version_contract, + ] + results = [] + for test in tests: + print(f"Running {test.__name__}...") + try: + test() + results.append(True) + except Exception as exc: + print(f"FAIL: {exc}") + import traceback + traceback.print_exc() + results.append(False) + print(f"Results: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) diff --git a/functional_tests/test_workflow_task_sequence.py b/functional_tests/test_workflow_task_sequence.py index d92e0890..2999ca36 100644 --- a/functional_tests/test_workflow_task_sequence.py +++ b/functional_tests/test_workflow_task_sequence.py @@ -94,12 +94,19 @@ def load_runner_helpers(dispatch, personal_runner_normalizer=None, group_runner_ "_get_document_action_config": lambda source: dict( (source or {}).get("document_action") or {"type": "none"} ), + "WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS": 8000, + "DOCUMENT_ACTION_TYPE_ANALYZE": "analyze", + "_get_workflow_file_sync_config": lambda workflow: (workflow or {}).get("file_sync") or {}, "uuid": uuid, } helpers = load_functions( RUNNER_FILE, { "_truncate_workflow_task_context", + "_truncate_workflow_file_sync_context", + "_format_workflow_file_sync_context", + "_apply_file_sync_changed_documents_to_action", + "_apply_file_sync_context_to_workflow", "_resolve_workflow_task_document_action", "_build_workflow_task_execution_workflow", "_get_workflow_task_requested_runner_mode", @@ -460,20 +467,38 @@ def dispatch(workflow, *_args, **_kwargs): } helpers, saved_items = load_runner_helpers(dispatch) - result = helpers["_execute_workflow_task_sequence"]( + # Build the File Sync context through the real producer instead of injecting + # file_sync_prompt_context by hand, so a missing producer cannot pass this test again. + execution_workflow = helpers["_apply_file_sync_context_to_workflow"]( { "id": "workflow-1", "name": "Sequence", "user_id": "user-1", "runner_type": "model", + "task_prompt": "Run the sequence.", "document_action": {"type": "search", "document_ids": ["doc-1"]}, - "file_sync_prompt_context": "File Sync context for this workflow run.", + "file_sync": {"use_changed_documents": False, "sources": []}, "tasks": [ {"id": "collect", "name": "Collect", "instructions": "Collect facts."}, {"id": "summarize", "name": "Summarize", "instructions": "Write a summary."}, ], "error_handling": {"strategy": "halt", "retry_count": 0}, }, + { + "enabled": True, + "counts": {"scanned": 3, "created": 1, "updated": 1, "unchanged": 1, "skipped": 0, "failed": 0}, + "changed_documents": [ + {"document_id": "doc-1", "relative_path": "reports/q3.pdf", "action": "created", "source_name": "Reports"}, + ], + "changed_document_ids": ["doc-1"], + }, + ) + assert execution_workflow["file_sync_prompt_context"], ( + "_apply_file_sync_context_to_workflow must publish file_sync_prompt_context for tasks." + ) + + result = helpers["_execute_workflow_task_sequence"]( + execution_workflow, {}, "conversation-1", "run-1", @@ -485,7 +510,9 @@ def dispatch(workflow, *_args, **_kwargs): assert [workflow["runner_type"] for workflow in dispatched_workflows] == ["model", "model"] assert dispatched_workflows[0]["document_action"]["type"] == "search" assert "[Workflow input context]" in dispatched_workflows[0]["task_prompt"] - assert "File Sync context for this workflow run." in dispatched_workflows[0]["task_prompt"] + assert "File Sync context for this workflow run" in dispatched_workflows[0]["task_prompt"] + assert "reports/q3.pdf" in dispatched_workflows[0]["task_prompt"] + assert "[Workflow input context]" not in dispatched_workflows[1]["task_prompt"] assert dispatched_workflows[1]["document_action"]["type"] == "none" assert "[Previous workflow task output]" in dispatched_workflows[1]["task_prompt"] assert "Result 1" in dispatched_workflows[1]["task_prompt"]