devinside.Marcin Borecki Ideas. Systems. Evidence.
← The notebook

Engineering

Treat Copilot Studio as a Dependency: A Local Direct Line Contract Harness

A real Direct Line client meets a local HTTP fixture to separate replay, partial results, errors and cancellation from business completion.

Explore the mechanism ↓
Editorial ink and watercolor illustration for Conversation progress is not task acceptance

START WITH THE MECHANISM

Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.

THE FULL INVESTIGATION

Treat Copilot Studio as a Dependency: A Local Direct Line Contract Harness

Editorial context: 26 March 2026 · Prepared and updated: 12 November 2026
Dates and technology context

This is a retrospective editorial timeline. Topic context: 26 March 2026. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.

17 local protocol checks, pinned dependency graph and cancellation/error observations are12Nov2026 evidence. Do not backdate deprecation status without a dated source.

An application that embeds a conversational service has two different success conditions. It can successfully exchange messages, and it can successfully complete the user's task. Those conditions often coincide in a demonstration. They separate quickly when a response is partial, an activity is replayed, a credential expires, or a request is canceled while the conversation remains alive.

The useful engineering question is therefore not whether a client can receive an HTTP 200. It is whether the application can preserve its own meaning of progress and completion across the dependency's conversation protocol. This article develops that boundary with a real C# Direct Line client and a small local HTTP server. The server is scripted. No Copilot Studio tenant, live bot, model, authentication service or external business action participates in the experiment.

That limitation is deliberate and substantial. A local protocol harness can expose mistakes in client state handling without pretending to establish the quality, availability or security of a hosted service. It can make a repeated page reproducible. It cannot prove that the real service will emit every possible activity sequence. It can return the documented expired-token shape. It cannot establish that a real token was issued, scoped or refreshed correctly.

The laboratory ran on SDK 10.0.400 and runtime 10.0.15. Its final report records 17 passing checks. These are functional observations, not a latency benchmark. The contribution is a concrete, inspectable boundary between transport outcomes, conversation state and an application-owned business result.

Choose the boundary before choosing the client

Microsoft documents Direct Line as one way to connect a custom application to a Copilot Studio agent. Its custom-application guidance also distinguishes obtaining a token through the Copilot Studio endpoint from directly using the Direct Line token-generation endpoint. Those details belong to a real integration and are not simulated as successful authentication here. See the official custom-application guidance.

Our boundary begins after a caller has a client object configured for a conversation endpoint. The laboratory substitutes an ephemeral loopback address for that endpoint and uses a conspicuous dummy token string. The server never validates a credential. It accepts the requests only because it is a controlled fixture, not because it implements an identity provider. The program does not read an account, request a private token or contact a deployed agent.

The client is the real Microsoft.Bot.Connector.DirectLine package, pinned to 3.0.2. Its legacy dependencies matter: the project explicitly pins Microsoft.Rest.ClientRuntime 2.3.24 and Newtonsoft.Json 13.0.3. This is a compatibility laboratory for the selected client surface, not a recommendation that a new application adopt an old dependency tree. The Direct Line package page and ClientRuntime package page are part of the source record; the latter identifies the package as deprecated and unmaintained.

During preparation, the unqualified dependency graph selected an older Newtonsoft.Json version with a restore warning. The delivered project resolves that specific dependency explicitly instead of suppressing the warning. That correction does not constitute a comprehensive security review of every transitive dependency. The lockfile makes the selected graph inspectable and reproducible, while the article keeps its claim restricted to the executed behavior.

The alternative is a small typed HttpClient adapter built against the current wire contract. That can reduce reliance on legacy client abstractions, but it transfers responsibility for serialization, errors and retry behavior into application code. The right comparison is between maintained boundaries with explicit tests. Replacing an SDK with hand-written HTTP does not automatically make an integration more reliable.

Separate conversation state from business state

01 / See the idea

Three states the client must not confuse

The fixture separates routing, reading progress and application completion.

The fixture separates routing, reading progress and application completion. Read the diagram within the local experiment boundary described in this section.

Interpretation and evidence
  • A protocol response is not a business result.
  • Recorded states belong to the local fixture.
  • Live authentication and service quality remain untested.
conceptual explanation of executed local fixture; not a timing trace

A conversation identifier tells the client where messages belong. A watermark tells it where to resume reading. An activity identifier lets the application recognize a repeated activity. None of these values, by itself, means that the user's requested business operation has completed.

The fixture makes that distinction visible by producing two bot messages for a submitted task: draft:task-A and accepted:task-A. The prefix accepted: is our application convention. It is not a Direct Line terminal-state protocol and should not be copied into a production system as if Microsoft guaranteed its meaning. A real application needs a structured, authenticated result contract appropriate to its domain.

For this small experiment, the draft is intentionally insufficient. A partial response mode exposes only that first activity. The gateway must return no business result. When the complete page is available, the gateway recognizes the later accepted message. Both requests can be successful HTTP exchanges, but only one satisfies the fixture's business rule.

This distinction changes the user interface. A successful poll can mean that new intermediate information is available, that nothing new has arrived, or that a completed result has arrived. Treating all three as a single success state encourages premature completion and confusing retries. A proposed application state model would separate waiting, partial progress, accepted result, recoverable dependency error and unresolved outcome. The laboratory implements the narrow accepted-result boundary, not a complete UI state machine.

There is also a distinction between displaying text and executing an effect. A bot message that says a reservation was accepted is not itself a durable reservation receipt. If the application performs an external action, that action needs its own authorization and idempotency contract. This harness executes no such action. Its result is a local string chosen to make the transport-to-domain boundary easy to inspect.

Exercise the actual HTTP path

The server exposes three selected routes: start a conversation, post an activity and retrieve activities. It binds to 127.0.0.1 on an operating-system-selected port. The client uses that actual address, so serialization, request construction, response decoding and asynchronous cancellation pass through a real HTTP stack.

using var client = new DirectLineClient("synthetic-token-not-a-credential")
{
    BaseUri = new Uri(app.Urls.Single())
};
client.HttpClient.Timeout = TimeSpan.FromSeconds(5);
var conversation = await client.Conversations.StartConversationAsync();

The startup response includes a conversation identifier and fixture token fields. It also includes an intentionally unimplemented WebSocket URL. The client under test uses HTTP polling only. A reader should not infer that the laboratory validates streaming, reconnection or the complete Direct Line protocol merely because one response contains a streamUrl field.

The official Direct Line concepts describe the service protocol. The laboratory deliberately implements a small subset sufficient for its selected client scenarios. Its server is not a replacement service, a conformance suite or a test double for arbitrary third-party clients.

The fixture stores conversations in memory and runs its checks serially. It is not designed for concurrent callers, process recovery or durable history. Those are explicit boundaries rather than hidden performance shortcuts. Starting a second conversation and observing an empty history demonstrates that this fixture and gateway preserve the selected conversation separation. It does not establish an authorization boundary between two real users.

That last distinction is easy to miss. Session isolation in a data structure means the histories have different keys. Tenant isolation means an unauthorized actor cannot read or act on another tenant's data. The latter requires identity, policy and service enforcement that are absent here. The names of two conversations cannot substitute for that evidence.

Make replay ordinary rather than exceptional

02 / See the idea

Replay and null cursors are explicit cases

Repeated activities do not create new accepted results; null does not erase the cursor.

Repeated activities do not create new accepted results; null does not erase the cursor. Read the diagram within the local experiment boundary described in this section.

Interpretation and evidence
  • A protocol response is not a business result.
  • Recorded states belong to the local fixture.
  • Live authentication and service quality remain untested.
conceptual explanation of executed local fixture; not a timing trace

The server intentionally returns previously seen activities again. This is a useful adversarial fixture because it prevents the client from assuming that every successful page contains only new information. The gateway keeps a set of activity identifiers and filters repeats before considering an accepted result.

foreach (var item in set.Activities)
{
    if (item.Id is not null && seen.Add(item.Id)
        && item.From?.Id == "bot"
        && item.Type == "message"
        && item.Text?.StartsWith("accepted:", StringComparison.Ordinal) == true)
    {
        accepted = item.Text;
    }
}
if (set.Watermark is not null)
    Watermark = set.Watermark;

The watermark is opaque to the gateway. The fixture happens to create strings such as fixture-wm-2, but the client does not parse the number or derive ordering from it. A separate test returns a null watermark after a valid one and checks that the previous cursor survives. This follows the receiving guidance's instruction to retain the preceding watermark when a response does not provide a new one. See receiving activities.

The in-memory set is enough for this bounded test, but it raises a real design question for a long-lived application. An unbounded set grows with conversation history. A process restart loses it. Persisting a cursor without persisting the corresponding consumed-result state can create another gap. The appropriate production mechanism depends on retention requirements and whether consuming a result creates an external effect.

For example, imagine a hypothetical support workflow that turns an accepted agent result into a draft ticket. If the draft creation and consumed-activity receipt are stored separately, a crash between them can cause duplicate drafts on replay. A durable operation key and an atomic receipt boundary would be a better next experiment than merely increasing the set's capacity. No durable receipt is implemented in this laboratory, and no exactly-once claim follows from its replay check.

The gateway also chooses only the last newly accepted string in a returned page. That is sufficient for the fixture's one-task-at-a-time schedule. It would be inadequate for concurrent outstanding tasks whose results need correlation. A production adapter should associate results with a task or operation identifier and define whether multiple accepted results are legal. The current test makes neither concurrency nor multi-result delivery promises.

Inspect errors below the convenience method

03 / A concrete example

Recorded failures, bounded requests

These are actual local responses through the pinned SDK, not service authentication tests.

These are actual local responses through the pinned SDK, not service authentication tests. Read the diagram within the local experiment boundary described in this section.

Interpretation and evidence
  • A protocol response is not a business result.
  • Recorded states belong to the local fixture.
  • Live authentication and service quality remain untested.
actual recorded functional outcomes, not performance

One of the most useful observations came from the client abstraction itself. The selected SDK did not surface every non-success status through the same exception path. The laboratory therefore uses the low-level response method when it needs to inspect the HTTP outcome, rather than assuming a convenience method will always throw a uniform exception.

using var response = await client.Conversations
    .GetActivitiesWithHttpMessagesAsync(conversationId, Watermark);
if (!response.Response.IsSuccessStatusCode)
    throw new HttpRequestException("DirectLine polling failed", null,
        response.Response.StatusCode);
var set = response.Body
    ?? throw new InvalidDataException("Missing activity set");

The final failure scenarios record an unauthorized response with status 401, an expired-token response with status 403 and code TokenExpired, and a synthetic throttle response with status 429. The first two shapes follow the documented distinction. An expired token is not modeled as an arbitrary 401. The API reference also distinguishes stable error codes from explanatory messages, so an application should avoid branching on human-readable message text.

The 429 case is a generic dependency-failure fixture. It establishes how this pinned client handles the supplied local response, not how often a hosted service throttles or which service-specific retry policy it recommends. The final fixture omits Retry-After and verifies one request for each failure scenario. Its custom retry strategy declines transient retries for the tested path.

That qualification is important because a preparation experiment with Retry-After produced additional requests despite the attempted no-retry configuration. The delivered one-request check therefore does not claim that every SDK retry mechanism is disabled for every response header. A production integration that depends on a strict attempt budget should test the exact headers and client path it will use, or place the attempt boundary somewhere it can observe and control.

This is a reason to preserve failures encountered during development. The unexpected retry behavior changed the scope of the claim. Hiding it behind a green final screenshot would encourage the next reader to infer a stronger guarantee than the experiment supports. The accepted fixture is bounded and useful precisely because its excluded case is named.

Error handling also needs to distinguish a failed read from an uncertain write. Repeating a poll may replay data; repeating a submitted activity may create another request for work. This laboratory tests polling failures, not ambiguous activity submission after a lost response. It would be unsafe to derive automatic POST retries from the successful GET failure scenarios. A separate operation identity and reconciliation contract would be needed for a workflow with effects.

Cancel the wait without inventing remote cancellation

04 / Watch the mechanism

Cancel a request, preserve the conversation

Cancellation and a client deadline both released the owned waiting request; history remained.
Enlarge diagram

Static view. Play to see how the mechanism unfolds.

Cancellation and a client deadline both released the owned waiting request; history remained. Read the diagram within the local experiment boundary described in this section.

Interpretation and evidence
  • A protocol response is not a business result.
  • Recorded states belong to the local fixture.
  • Live authentication and service quality remain untested.
conceptual explanation of executed local fixture; not a timing trace

The timeout endpoint waits until its request is aborted or the owned server is shut down. A synchronization signal tells the test that the request has actually entered the waiting endpoint. Only then does the test cancel its client token. This avoids treating a cancellation that happened before network work began as evidence about an in-flight request.

The client operation completes with cancellation, the server observes termination, and the count of active waiting requests returns to zero. The test then retrieves the conversation again and confirms that its four activities still exist. Canceling a request did not delete the conversation, undo a message or cancel a remote business process.

A second case uses an actual HttpClient.Timeout of 250 milliseconds with a separate client instance. The server-entry and server-finished signals again establish that the request started and ended. The report records a timeout and zero active requests. The 250 milliseconds is a configured deadline, not an observed service latency, a performance result or a recommended production setting.

The surrounding checks use finite watchdog waits so a broken cancellation path cannot leave the harness waiting indefinitely. In the cleanup path, an owned cancellation source also terminates the server's waiting branch. The program disposes clients, stops the application and leaves no listener running after the test command ends.

The distinction matters in a real user experience. Closing a browser tab or canceling an HTTP request can stop local waiting while work continues elsewhere. A UI that immediately reports "operation canceled" may be asserting something the backend never confirmed. For a consequential operation, unresolved or reconciling may be the honest state until a separate status query establishes the final outcome.

Our server has no asynchronous remote job, so it cannot demonstrate that reconciliation protocol. It demonstrates the narrower prerequisite: request cancellation is propagated and cleaned up without confusing it with conversation deletion. That is a useful result on its own, provided it is not expanded into a claim about every downstream activity.

Read the recorded states as a contract table

The final run recorded 17 successful checks on runtime 10.0.15. The table groups the checks by observable contract rather than pretending that each row is an independent load test.

Scenario Recorded outcome What remains outside the claim
Partial page Draft produces no accepted result General language understanding
Complete page accepted:task-A recognized A durable external effect
Replayed page Seen count does not increase Restart-safe deduplication
Null watermark Previous cursor retained Arbitrary cursor corruption
Second conversation Empty independent history Real user authorization
Conversation reuse Four distinct activities retained Concurrent outstanding tasks
Unauthorized response 401, one request Credential validation
Expired-token shape 403, TokenExpired, one request Live refresh or token issuance
Synthetic throttle 429, one request without Retry-After All SDK retry paths
Explicit cancellation Canceled, active request count zero Remote job cancellation
Client deadline Timed out, active request count zero Hosted service latency

The table is an acceptance summary, not a score. Passing more local cases does not turn the fixture into a live Copilot Studio end-to-end test. It does make specific client decisions reproducible and gives reviewers concrete places to disagree with the application contract.

For example, a reviewer may reject the accepted-prefix rule because the application needs signed structured results. That is a useful disagreement. The test can then move its completion predicate to the real result schema while preserving the partial-page and replay cases. Another reviewer may require durable deduplication. That changes storage and crash testing, rather than merely adding another assertion to the current in-memory set.

The report intentionally contains no throughput or percentile latency numbers. The loopback server responds according to a script and its delays are chosen to exercise cancellation. Timing it would mostly measure the fixture, local scheduling and client mechanics. Such a number would answer a different question from the dependency behavior being examined here.

Decide what a live integration must add

05 / See the idea

The evidence boundary stays visible

The final run establishes selected client contracts, not live tenant or model behavior.

The final run establishes selected client contracts, not live tenant or model behavior. Read the diagram within the local experiment boundary described in this section.

Interpretation and evidence
  • A protocol response is not a business result.
  • Recorded states belong to the local fixture.
  • Live authentication and service quality remain untested.
conceptual explanation of executed local fixture; not a timing trace

The strongest counterargument to a local harness is that it can become a comfortable substitute for integration testing. That risk is real. A server written by the same team as the client can reproduce the team's misunderstandings, and a narrow subset may omit the behavior most likely to fail in production.

The response is to use the harness for repeatable adversarial cases and use a real integration environment for the boundaries it cannot establish. The two forms of evidence should have different labels. A local contract case can be fast, deterministic and safe to run frequently. A live test can establish that deployment configuration, identity, regional endpoints and the actual agent are connected as intended. Neither should silently stand in for the other.

Before adding a live environment, an application owner should specify the business result it will accept and the identity allowed to request it. A transport token is not automatically proof of an end user's permission to view a result or trigger an action. The Direct Line authentication guidance describes service credentials and conversation tokens; application authorization remains a separate design responsibility.

Other missing boundaries include WebSocket reconnection, activity types beyond the selected messages, attachment handling, durable cursor storage, malformed response bodies, concurrent conversation use and token refresh. These are not claims that the integration is defective. They are named questions that determine whether this small adapter is sufficient for a particular product.

A useful next contract would test an uncertain submission outcome. The server could accept a task, lose the response and later expose a task receipt through a separate query. The client would have to reconcile the original operation rather than submit it again blindly. That would teach a different lesson from polling replay and should have its own expected outcomes. It is proposed work, not a hidden feature of this repository.

Put the adapter behind an application contract

The gateway in the laboratory is intentionally visible and small. In an application, its consumer should not need to understand every SDK response wrapper. It should receive a result that preserves the distinctions the product actually needs: no new result, partial progress, accepted task outcome, dependency failure and uncertain submission. This is a proposed interface boundary rather than another implemented library in the download.

Consider a hypothetical document-review tool. The user submits one document and expects a review artifact associated with that document's immutable revision. A message saying that review has started is useful progress, but it cannot satisfy the completion condition. An accepted result for yesterday's revision is also insufficient. The application should correlate a structured result with the submitted operation and document revision before making it visible as the current review. The fixture's task string represents only the smallest part of that correlation problem.

This makes the adapter's ownership clearer. It owns conversation reuse, cursor handling, activity decoding and the translation of dependency failures into application states. The domain layer owns whether a result is acceptable for a particular document, actor and revision. The presentation layer owns how to show waiting or uncertainty without fabricating progress. Combining all three into an event handler often produces code that is easy to demonstrate and difficult to repair after a partial failure.

An application contract also helps replacement. If a team changes the conversational dependency, the transport adapter may change extensively while the meaning of an accepted document review remains stable. Conversely, if the business rule changes, keeping the same SDK does not preserve application correctness automatically. This is why a package-level success check and a domain-level acceptance check answer different questions, even when both run in the same process.

There is a cost to adding layers. A tiny internal tool may not need a general-purpose orchestration framework, durable outbox or elaborate state machine. The point is not to maximize abstraction. It is to put the few consequential decisions in named places where tests can reach them. In this repository those places are the gateway's completion predicate, its seen-activity set, its watermark assignment and the explicit HTTP status inspection. Each corresponds to an observed failure possibility rather than a speculative extension point.

Observability should follow the same ownership. A useful local trace can record conversation identity, request kind, response status, activity identity and the application transition it caused. It should avoid treating the entire conversation text as necessary telemetry. In a real product, message content can contain sensitive user data, so diagnostic usefulness and retention policy need an intentional design. The current output contains only synthetic task strings and generated fixture conversation identifiers.

Finally, the consumer should be able to distinguish a dependency outage from a rejected business result. If polling fails, it does not follow that the document review failed. If the agent returns an explicit rejection, retrying the same transport request may not change the decision. If a submission outcome is unknown, creating a new conversation can lose the connection to work already started. These are separate transitions with separate recovery actions. A protocol harness earns its place when it keeps those distinctions visible in executable examples.

The most important operational artifact is consequently a short explanation of each transition. When a support engineer sees a repeated page, the trace should explain that it was ignored as already consumed, not merely show a second successful HTTP call. When a deadline expires, the trace should retain the conversation identity and mark the wait as ended without inventing a final task outcome. These explanations can be generated from structured state changes rather than reconstructed from a wall of message text. The laboratory's small result file is an initial example of that discipline, not a complete production telemetry design.

Reproduce the bounded experiment

Run the delivered project from its lab directory with the pinned SDK available. Package restore uses public NuGet metadata and archives. The test itself uses only its own loopback server and dummy fixture values.

dotnet run -c Release -- ../check-results.json

The output file records the check count, runtime, scenario outcomes and explicit scope. A failing assertion stops acceptance; it should not be replaced with a manually edited result. The source and lockfile travel with the report so a reader can inspect both the client package and the tiny server that supplied its responses.

Reproduction is most valuable when it includes a counterexample. Remove the activity-ID filter and the replay case should fail. Clear the watermark whenever a null arrives and the retention case should fail. Treat the draft prefix as completion and the partial-page case should fail. Those changes are suggested experiments, not additional mutation runs claimed by this article.

The central design decision is to let the application own the meaning of completion while respecting the dependency's conversation protocol. A response can be delivered without completing a task. A page can repeat without authorizing another effect. A wait can end while the conversation survives. Once those distinctions are represented in code and tested through the actual client path, a conversational dependency becomes something the application can reason about rather than merely hope will behave like a demonstration.

Source code and execution evidence

Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.

THE AUTHOR

Marcin Borecki

Developer, entrepreneur and business angel. Writing about AI, software, data and the evidence behind technical decisions.

About the author →